From 7b52cab180e56a08f22aad51694e1ef552b6c0e4 Mon Sep 17 00:00:00 2001 From: Ross Date: Wed, 25 Mar 2026 22:05:19 +0000 Subject: [PATCH] Refactor SelectionTable to use per-table initializers for row selection controls and improve JavaScript handling in templates --- generic/tables.py | 163 ++++++++++++----------- rad.code-workspace | 4 + templates/base.html | 10 ++ templates/django_tables2/bootstrap4.html | 100 ++++++++++++++ 4 files changed, 199 insertions(+), 78 deletions(-) create mode 100644 templates/django_tables2/bootstrap4.html diff --git a/generic/tables.py b/generic/tables.py index a8745ecc..7b7f383c 100644 --- a/generic/tables.py +++ b/generic/tables.py @@ -18,8 +18,6 @@ from django.contrib.auth.models import User # column (input name="selection") and table attrs that client-side # scripts can use to find selectable tables. class SelectionTable(tables.Table): - # Provide a default selection checkbox column. Subclasses that wish to - # customise can override this attribute. selection = tables.CheckBoxColumn( accessor="pk", orderable=False, @@ -32,16 +30,9 @@ class SelectionTable(tables.Table): ) class Meta: - # Defaults: add js-row-selectable class so client-side code can - # locate tables that support row selection. attrs = {"class": "table js-row-selectable", "data-selection-name": "selection"} def __init__(self, *args, **kwargs): - # Ensure selection column is always placed last in the column order - # so that table actions/controls appear to the right. We attempt to - # reorder columns after initialization; if django-tables2 provides - # `reorder_columns`, use it, otherwise fall back to setting - # `self.sequence`. super().__init__(*args, **kwargs) try: cols = list(self.columns.names()) @@ -49,19 +40,12 @@ class SelectionTable(tables.Table): cols.remove('selection') cols.append('selection') try: - # Preferred API when available self.reorder_columns(cols) except Exception: - # Fallback: set sequence attribute self.sequence = tuple(cols) except Exception: - # Defensive: don't break table rendering if introspection fails pass - # Expose a per-instance selection token and ensure the table has the - # `js-row-selectable` class so client-side scripts (or the inline - # initializer below) can find the correct table without extra markup - # in templates. try: token = format(id(self), 'x') attrs = getattr(self, 'attrs', None) or {} @@ -71,77 +55,100 @@ class SelectionTable(tables.Table): attrs['data-selection-token'] = token self.attrs = attrs except Exception: - # non-fatal pass - # By default render the row selection controls above the table so - # templates don't need to include `{{ table.row_selection_controls }}`. - try: - # `row_selection_controls` is already marked safe HTML. - self.caption = self.row_selection_controls - except Exception: - pass + self.caption = self.row_selection_controls @property def row_selection_controls(self): - """Return HTML for the row-selection control bar. + token = (self.attrs or {}).get('data-selection-token', format(id(self), 'x')) - Usage: render in template with `{{ table.row_selection_controls|safe }}` + html = ( + f"
" + f"
" + f" " + f"
" + f" " + f" " + f"
" + f"
" + f"
" + f" Selected: 0" + f"
" + f"
" + ) - The control IDs are suffixed with a small per-instance token to - avoid collisions when multiple tables are present on the same page. - """ - token = (self.attrs or {}).get('data-selection-token', format(id(self), 'x')) + script = """ +" - ).format(t=token) + var selectionEnabled = false; + var lastChecked = null; - return mark_safe(html + script) + function findRowCheckboxes() { var checks = Array.from(table.querySelectorAll('input[type="checkbox"][name="selection"]')); if (!checks.length) checks = Array.from(table.querySelectorAll('input[type="checkbox"]')); return checks; } + function updateSelectedCount() { if (!selectedCountSpan) return; var checks = findRowCheckboxes(); var count = checks.filter(function(c){ return c.checked; }).length; selectedCountSpan.textContent = count; checks.forEach(function(cb){ var tr = cb.closest('tr'); if (tr) tr.classList.toggle('table-active', cb.checked); }); } + function setCheckboxesDisabled(disabled) { findRowCheckboxes().forEach(function(cb){ cb.disabled = disabled; }); lastChecked = null; } + function hideSelectionColumn() { table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = 'none'; }); } + function showSelectionColumn() { table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = ''; }); } + + setCheckboxesDisabled(true); hideSelectionColumn(); + + table.addEventListener('click', function(e){ + if (!selectionEnabled) return; + var tag = (e.target.tagName || '').toLowerCase(); + if (['a','button','select','textarea','label'].indexOf(tag) !== -1) return; + if (e.target && e.target.matches && e.target.matches('input[type="checkbox"]')){ + var cb = e.target; + if (e.shiftKey && lastChecked && lastChecked !== cb){ + var checks = findRowCheckboxes(); + var start = checks.indexOf(lastChecked); + var end = checks.indexOf(cb); + if (start > -1 && end > -1){ + if (start > end){ var tmp = start; start = end; end = tmp; } + for (var i = start; i <= end; i++){ if (!checks[i].disabled) checks[i].checked = cb.checked; } + updateSelectedCount(); + } + } + lastChecked = cb; + return; + } + var tr = e.target.closest('tr'); if (!tr || !table.contains(tr)) return; + var cb = tr.querySelector('input[type="checkbox"][name="selection"]') || tr.querySelector('input[type="checkbox"]'); + if (cb && !cb.disabled){ cb.checked = !cb.checked; tr.classList.toggle('table-active', cb.checked); updateSelectedCount(); lastChecked = cb; } + }, true); + + table.addEventListener('change', function(e){ if (e.target && e.target.matches && e.target.matches('input[type="checkbox"]')) updateSelectedCount(); }, true); + + toggleBtn.addEventListener('click', function(){ selectionEnabled = !selectionEnabled; if (selectionEnabled){ toggleBtn.textContent = 'Disable row selection'; if (controlsEl) controlsEl.style.display = ''; setCheckboxesDisabled(false); showSelectionColumn(); } else { toggleBtn.textContent = 'Enable row selection'; if (controlsEl) controlsEl.style.display = 'none'; setCheckboxesDisabled(true); hideSelectionColumn(); findRowCheckboxes().forEach(function(cb){ cb.checked = false; }); updateSelectedCount(); lastChecked = null; } }); + + if (selectAllBtn) selectAllBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = true; }); updateSelectedCount(); lastChecked = null; }); + if (clearBtn) clearBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = false; }); updateSelectedCount(); lastChecked = null; }); + + updateSelectedCount(); + }; +})(); + +""" + + # Replace the simple placeholder with the runtime token. Using + # a plain triple-quoted string avoids f-string brace parsing errors + # from the JavaScript code which contains many `{`/`}` characters. + script = script.replace('__TOKEN__', token) + + return mark_safe(html + script) class SingleImageColumn(tables.Column): def render(self, value): diff --git a/rad.code-workspace b/rad.code-workspace index feb2c722..c0f24d9d 100644 --- a/rad.code-workspace +++ b/rad.code-workspace @@ -15,6 +15,10 @@ "/^source \\.venv/bin/activate\\.fish && python manage\\.py check$/": { "approve": true, "matchCommandLine": true + }, + "/^python -m py_compile /home/ross/penracourses/rad/generic/tables\\.py$/": { + "approve": true, + "matchCommandLine": true } } } diff --git a/templates/base.html b/templates/base.html index bf743ebb..2b0f0459 100644 --- a/templates/base.html +++ b/templates/base.html @@ -348,6 +348,16 @@ // Selection wiring has been moved into per-table initializers // emitted by `SelectionTable.row_selection_controls`. + try { + if (window.__selection_inits) { + Object.keys(window.__selection_inits).forEach(function(k){ + try { window.__selection_inits[k](); } catch (e) { console.error('selection init failed', k, e); } + try { delete window.__selection_inits[k]; } catch (e) {} + }); + } + } catch (err) { + console.error('Error running selection initializers', err); + } }); diff --git a/templates/django_tables2/bootstrap4.html b/templates/django_tables2/bootstrap4.html new file mode 100644 index 00000000..ddf81751 --- /dev/null +++ b/templates/django_tables2/bootstrap4.html @@ -0,0 +1,100 @@ +{% load django_tables2 %} +{% load i18n l10n %} +{% block table-wrapper %} +
+ {% if table.caption %} + {{ table.caption|safe }} + {% endif %} + {% block table %} + + {% block table.thead %} + {% if table.show_header %} + + + {% for column in table.columns %} + + {% endfor %} + + + {% endif %} + {% endblock table.thead %} + {% block table.tbody %} + + {% for row in table.paginated_rows %} + {% block table.tbody.row %} + + {% for column, cell in row.items %} + + {% endfor %} + + {% endblock table.tbody.row %} + {% empty %} + {% if table.empty_text %} + {% block table.tbody.empty_text %} + + {% endblock table.tbody.empty_text %} + {% endif %} + {% endfor %} + + {% endblock table.tbody %} + {% block table.tfoot %} + {% if table.has_footer %} + + + {% for column in table.columns %} + + {% endfor %} + + + {% endif %} + {% endblock table.tfoot %} +
+ {% if column.orderable %} + {{ column.header }} + {% else %} + {{ column.header }} + {% endif %} +
{% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %}
{{ table.empty_text }}
{{ column.footer }}
+ {% endblock table %} + + {% block pagination %} + {% if table.page and table.paginator.num_pages > 1 %} + + {% endif %} + {% endblock pagination %} +
+{% endblock table-wrapper %}