Skip to content

Commit

Permalink
Merge pull request #35470 from dimagi/bmb/htmx-django-tables
Browse files Browse the repository at this point in the history
Add django-tables2 and utils, example, docs for usage with HTMX
  • Loading branch information
biyeun authored Jan 9, 2025
2 parents ce3116e + 24853e4 commit bb07b54
Show file tree
Hide file tree
Showing 33 changed files with 958 additions and 47 deletions.
2 changes: 2 additions & 0 deletions corehq/apps/hqwebapp/static/hqwebapp/js/htmx_base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
@@ -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');
}
}
});
Original file line number Diff line number Diff line change
@@ -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');
});
92 changes: 92 additions & 0 deletions corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_tables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Empty file.
50 changes: 50 additions & 0 deletions corehq/apps/hqwebapp/tables/htmx.py
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions corehq/apps/hqwebapp/tables/pagination.py
Original file line number Diff line number Diff line change
@@ -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
122 changes: 122 additions & 0 deletions corehq/apps/hqwebapp/templates/hqwebapp/tables/bootstrap5.html
Original file line number Diff line number Diff line change
@@ -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 %}
<div
class="table-container"
{% if table.container_id %}
id="{{ table.container_id }}"
{% endif %}
{% block table-container-attrs %}{% endblock %}
>

{% block before_table %}{% endblock %}

{% block table %}{{ block.super }}{% endblock table %}

<div class="pb-3 d-flex justify-content-between">
<div>
{% block num_entries %}
<div class="input-group">
<div class="input-group-text">
{% block num_entries.text %}
{% with start=table.page.start_index end=table.page.end_index total=table.page.paginator.count %}
{% blocktranslate %}
Showing {{ start }} to {{ end }} of {{ total }} entries
{% endblocktranslate %}
{% endwith %}
{% endblock num_entries.text %}
</div>
{% block num_entries.select %}
<select
class="form-select"
{% block select-per-page-attr %}{% endblock %}
>
{% for p in table.paginator.paging_options %}
<option
value="{{ p }}"
{% if p == table.paginator.per_page %} selected{% endif %}
>
{% blocktrans %}
{{ p }} per page
{% endblocktrans %}
</option>
{% endfor %}
</select>
{% endblock %}
</div>
{% endblock num_entries %}
</div>
<div>
{% block pagination %}
{% if table.page and table.paginator.num_pages > 1 %}
<nav aria-label="Table navigation">
<ul class="pagination">

{% block pagination.previous %}
<li class="previous page-item{% if not table.page.has_previous %} disabled{% endif %}">
<a
class="page-link"
{% if table.page.has_previous %}
{% block prev-page-link-attr %}
href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}"
{% endblock %}
{% endif %}
>
{% trans 'Previous' %}
</a>
</li>
{% endblock pagination.previous %}

{% if table.page.has_previous or table.page.has_next %}
{% block pagination.range %}
{% for p in table.page|table_page_range:table.paginator %}
<li class="page-item{% if table.page.number == p %} active{% endif %}">
<a
class="page-link"
{% if p != '...' %}
href="{% querystring table.prefixed_page_field=p %}"
{% endif %}
>
{{ p }}
</a>
</li>
{% endfor %}
{% endblock pagination.range %}
{% endif %}

{% block pagination.next %}
<li class="next page-item{% if not table.page.has_next %} disabled{% endif %}">
<a
class="page-link"
{% if table.page.has_next %}
{% block next-page-link-attr %}
href="{% querystring table.prefixed_page_field=table.page.next_page_number %}"
{% endblock %}
{% endif %}
>
{% trans 'Next' %}
</a>
</li>
{% endblock pagination.next %}

</ul>
</nav>
{% endif %}
{% endblock pagination %}
</div>
</div>

{% block after_table %}{% endblock %}

</div>
{% endblock table-wrapper %}

{% block table.thead %}
{% if table.show_header %}
{% render_header %}
{% endif %}
{% endblock table.thead %}
Loading

0 comments on commit bb07b54

Please sign in to comment.