From 103c4aa805f3650df248f2fe3aeca6f4be91cd0e Mon Sep 17 00:00:00 2001 From: Viliam Mihalik Date: Mon, 20 Apr 2026 12:22:38 +0200 Subject: [PATCH 1/6] triv: added blocks to be able to add own html --- pyproject.toml | 2 +- src/django_smartbase_admin/templates/sb_admin/navigation.html | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e8a8262..bfd16e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-smartbase-admin" -version = "1.1.7" +version = "1.1.7b1" description = "" authors = ["SmartBase "] readme = "README.md" diff --git a/src/django_smartbase_admin/templates/sb_admin/navigation.html b/src/django_smartbase_admin/templates/sb_admin/navigation.html index 3be5b3e..9252685 100644 --- a/src/django_smartbase_admin/templates/sb_admin/navigation.html +++ b/src/django_smartbase_admin/templates/sb_admin/navigation.html @@ -101,6 +101,7 @@ {# #} {# #} + {% block before_footer %}{% endblock %}
{# Global Search #} {#
#} @@ -176,5 +177,6 @@
+ {% block after_navigation %}{% endblock %} {% endif %} {% endblock %} From c9349e9bdefe61d2a9aa291da05e7cd1a7d77b52 Mon Sep 17 00:00:00 2001 From: Viliam Mihalik Date: Wed, 22 Apr 2026 13:09:53 +0200 Subject: [PATCH 2/6] fix: removed field_cache to be able to do request-based sb_admin_list --- .../engine/admin_base_view.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/django_smartbase_admin/engine/admin_base_view.py b/src/django_smartbase_admin/engine/admin_base_view.py index 5f809d2..e8bef32 100644 --- a/src/django_smartbase_admin/engine/admin_base_view.py +++ b/src/django_smartbase_admin/engine/admin_base_view.py @@ -151,8 +151,6 @@ def get_field_map(self, request) -> dict[str, "SBAdminField"]: return self.field_cache def init_fields_cache(self, fields_source, configuration, force=False): - if not force and self.field_cache: - return self.field_cache.values() from django_smartbase_admin.engine.field import SBAdminField fields = [] @@ -318,6 +316,7 @@ class SBAdminBaseListView(SBAdminBaseView): sbadmin_table_history_enabled = True sbadmin_list_history_enabled = True sbadmin_list_reorder_field = None + sbadmin_list_sticky_footer = None search_field_placeholder = _("Search...") filters_version = None sbadmin_actions_initialized = False @@ -480,8 +479,14 @@ def has_add_permission(self, request, obj=None) -> bool: return False return super().has_add_permission(request) + def get_sbadmin_list_sticky_footer(self, request) -> bool: + if self.sbadmin_list_sticky_footer is not None: + return self.sbadmin_list_sticky_footer + return request.request_data.configuration.default_list_sticky_footer + def get_tabulator_definition(self, request) -> dict[str, Any]: view_id = self.get_id() + sticky_footer = self.get_sbadmin_list_sticky_footer(request) tabulator_definition = { "viewId": view_id, "advancedFilterId": f"{view_id}" + "-advanced-filter", @@ -500,6 +505,7 @@ def get_tabulator_definition(self, request) -> dict[str, Any]: "tableInitialSort": self.get_list_initial_order(request), "tableInitialPageSize": self.get_list_per_page(request), "tableHistoryEnabled": self.sbadmin_table_history_enabled, + "stickyFooter": sticky_footer, # used to initialize all columns with these values "defaultColumnData": {}, "locale": request.LANGUAGE_CODE, @@ -538,6 +544,8 @@ def get_tabulator_definition(self, request) -> dict[str, Any]: "headerTabsModule", ] ) + if sticky_footer: + tabulator_definition["modules"].append("stickyFooterModule") return tabulator_definition def _get_sbadmin_list_actions(self, request) -> list[SBAdminCustomAction] | list: @@ -761,7 +769,9 @@ def get_all_config(self, request) -> dict[str, Any]: if not list_filter: return all_config list_fields = self.get_sbadmin_list_display(request) or [] - self.init_fields_cache(list_fields, request.request_data.configuration) + list_fields = self.init_fields_cache( + list_fields, request.request_data.configuration + ) base_filter = { getattr(field, "filter_field", field): "" for field in list_fields From a24e9affb9f9bcb8966dbab5500a6eef2eb17da8 Mon Sep 17 00:00:00 2001 From: Viliam Mihalik Date: Wed, 22 Apr 2026 15:28:22 +0200 Subject: [PATCH 3/6] feat: added possible to add optgroup in choice filter --- AGENTS.md | 21 +++++++++++ .../engine/filter_widgets.py | 34 ++++++++++++++++- .../advanced_filters/choice_field.html | 16 +++++--- .../multiple_choice_field.html | 37 +++++++++++-------- .../sb_admin/filter_widgets/choice_field.html | 8 +++- .../filter_widgets/multiple_choice_field.html | 31 +++++++++------- 6 files changed, 109 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b692c1e..40f8bf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -866,6 +866,26 @@ Styles live in `static/sb_admin/src/css/_tabulator.css`: the icon is hidden on s > **Recommendation:** Prefer `MultipleChoiceFilterWidget` over `ChoiceFilterWidget` for choice-based filters. It provides a better UX and gives users more flexibility to select multiple values at once. +### Grouped Choices + +Both `ChoiceFilterWidget` and `MultipleChoiceFilterWidget` accept Django-style grouped choices in addition to the flat form. Grouped input renders a header (`` in select templates, a styled header `
  • ` in the checkbox-dropdown templates); flat input renders identically to before. + +```python +# Flat (unchanged) +MultipleChoiceFilterWidget(choices=[ + ("draft", "Draft"), + ("published", "Published"), +]) + +# Grouped — shipper is the group header +MultipleChoiceFilterWidget(choices=[ + ("GLS", [("1", "Insurance"), ("2", "Signature required")]), + ("SPS", [("5", "Special handling"), ("6", "Overweight")]), +]) +``` + +Detection follows the same rule Django's `ChoiceWidget.optgroups` uses: the top-level structure is grouped if the second element of the first item is a list/tuple. Mixing flat and grouped choices in the same call is not supported. + ### Custom Filter Widget Example ```python @@ -2708,6 +2728,7 @@ Quick reference for all `sbadmin_` prefixed class attributes available in `SBAdm | `sbadmin_list_reorder_field` | str | Field name for drag-and-drop row reordering | | `sbadmin_xlsx_options` | dict | Excel export configuration options | | `sbadmin_table_history_enabled` | bool | Enable/disable table state history (default: `True`) | +| `sbadmin_list_sticky_footer` | bool \| None | Stick pagination footer to viewport bottom on scroll. On desktop, adds a synced horizontal scrollbar above it that mirrors the table's horizontal scroll. `None` (default) falls back to `SBAdminRoleConfiguration.default_list_sticky_footer`; explicit `True`/`False` overrides the global setting. | ### Detail/Change View Attributes (SBAdmin) diff --git a/src/django_smartbase_admin/engine/filter_widgets.py b/src/django_smartbase_admin/engine/filter_widgets.py index d378be2..eebd4e2 100644 --- a/src/django_smartbase_admin/engine/filter_widgets.py +++ b/src/django_smartbase_admin/engine/filter_widgets.py @@ -257,13 +257,45 @@ def __init__( ) self.choices = self.choices or choices + @property + def grouped_choices(self): + """Normalise ``choices`` into ``[(group_label_or_None, [(value, label), ...])]``. + + Accepts flat ``[(value, label), ...]`` and Django-style grouped + ``[(group_label, [(value, label), ...]), ...]``. Flat input becomes a + single ``None``-labelled group so templates iterate uniformly and skip + the header when ``group_label`` is falsy. Mirrors the detection + ``ChoiceWidget.optgroups`` uses internally. + """ + if not self.choices: + return [] + items = list(self.choices) + first = items[0] + is_grouped = ( + isinstance(first, (list, tuple)) + and len(first) == 2 + and isinstance(first[1], (list, tuple)) + ) + if is_grouped: + return [(group_label, list(options)) for group_label, options in items] + return [(None, items)] + + @property + def flat_choices(self): + """Flat ``[(value, label), ...]`` view of ``choices`` — same list for + both flat and grouped input. Use this for label lookup.""" + flat = [] + for _, options in self.grouped_choices: + flat.extend(options) + return flat + def get_default_label(self): if self.default_label: return self.default_label else: default_value = self.get_default_value() found_label = [ - label for value, label in self.choices if value == default_value + label for value, label in self.flat_choices if value == default_value ] return found_label[0] if found_label else default_value diff --git a/src/django_smartbase_admin/templates/sb_admin/filter_widgets/advanced_filters/choice_field.html b/src/django_smartbase_admin/templates/sb_admin/filter_widgets/advanced_filters/choice_field.html index 16a787a..8405741 100644 --- a/src/django_smartbase_admin/templates/sb_admin/filter_widgets/advanced_filters/choice_field.html +++ b/src/django_smartbase_admin/templates/sb_admin/filter_widgets/advanced_filters/choice_field.html @@ -4,11 +4,15 @@ id="{{ filter_widget.input_id }}" name="{{ filter_widget.input_name }}" > - {% for choice in filter_widget.choices %} - + {% for group_label, group_choices in filter_widget.grouped_choices %} + {% if group_label %}{% endif %} + {% for choice in group_choices %} + + {% endfor %} + {% if group_label %}{% endif %} {% endfor %} diff --git a/src/django_smartbase_admin/templates/sb_admin/filter_widgets/advanced_filters/multiple_choice_field.html b/src/django_smartbase_admin/templates/sb_admin/filter_widgets/advanced_filters/multiple_choice_field.html index d5fd0b0..9859775 100644 --- a/src/django_smartbase_admin/templates/sb_admin/filter_widgets/advanced_filters/multiple_choice_field.html +++ b/src/django_smartbase_admin/templates/sb_admin/filter_widgets/advanced_filters/multiple_choice_field.html @@ -19,22 +19,27 @@ id="{{ filter_widget.input_id }}" name="{{ filter_widget.input_name }}">
      - {% for choice in filter_widget.choices %} -
    • -
      - - -
      -
    • + {% for group_label, group_choices in filter_widget.grouped_choices %} + {% if group_label %} +
    • {{ group_label }}
    • + {% endif %} + {% for choice in group_choices %} +
    • +
      + + +
      +
    • + {% endfor %} {% endfor %}
    diff --git a/src/django_smartbase_admin/templates/sb_admin/filter_widgets/choice_field.html b/src/django_smartbase_admin/templates/sb_admin/filter_widgets/choice_field.html index 0eae2e5..cb960da 100644 --- a/src/django_smartbase_admin/templates/sb_admin/filter_widgets/choice_field.html +++ b/src/django_smartbase_admin/templates/sb_admin/filter_widgets/choice_field.html @@ -4,8 +4,12 @@ id="{{ filter_widget.input_id }}" name="{{ filter_widget.input_name }}" {% if not all_filters_visible %}disabled{% endif %}> - {% for choice in filter_widget.choices %} - + {% for group_label, group_choices in filter_widget.grouped_choices %} + {% if group_label %}{% endif %} + {% for choice in group_choices %} + + {% endfor %} + {% if group_label %}{% endif %} {% endfor %} diff --git a/src/django_smartbase_admin/templates/sb_admin/filter_widgets/multiple_choice_field.html b/src/django_smartbase_admin/templates/sb_admin/filter_widgets/multiple_choice_field.html index 3bb85cc..1c75f0f 100644 --- a/src/django_smartbase_admin/templates/sb_admin/filter_widgets/multiple_choice_field.html +++ b/src/django_smartbase_admin/templates/sb_admin/filter_widgets/multiple_choice_field.html @@ -20,19 +20,24 @@
  • {% endif %} - {% for choice in filter_widget.choices %} -
  • -
    - - -
    -
  • + {% for group_label, group_choices in filter_widget.grouped_choices %} + {% if group_label %} +
  • {{ group_label }}
  • + {% endif %} + {% for choice in group_choices %} +
  • +
    + + +
    +
  • + {% endfor %} {% endfor %} From 2cd5fbe415b1a957d789e5bf760b81dc38403d08 Mon Sep 17 00:00:00 2001 From: Viliam Mihalik Date: Wed, 22 Apr 2026 15:29:38 +0200 Subject: [PATCH 4/6] feat: added the possibility to stick footer on tables with sticky scrollbar --- .../engine/configuration.py | 4 ++ .../static/sb_admin/src/css/_tabulator.css | 35 ++++++++++- .../static/sb_admin/src/js/table.js | 2 + .../js/table_modules/sticky_footer_module.js | 60 +++++++++++++++++++ .../templates/sb_admin/actions/list.html | 17 ++++-- 5 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 src/django_smartbase_admin/static/sb_admin/src/js/table_modules/sticky_footer_module.js diff --git a/src/django_smartbase_admin/engine/configuration.py b/src/django_smartbase_admin/engine/configuration.py index e0a840a..8c818c5 100644 --- a/src/django_smartbase_admin/engine/configuration.py +++ b/src/django_smartbase_admin/engine/configuration.py @@ -187,6 +187,7 @@ class SBAdminRoleConfiguration(metaclass=Singleton): default_color_scheme = ColorScheme.AUTO login_view_class = LoginView admin_title = "SBAdmin" + default_list_sticky_footer = False def __init__( self, @@ -198,6 +199,7 @@ def __init__( default_color_scheme=None, login_view_class=None, admin_title=None, + default_list_sticky_footer=None, ) -> None: super().__init__() self.default_view = default_view or self.default_view or [] @@ -210,6 +212,8 @@ def __init__( self.default_color_scheme = default_color_scheme or self.default_color_scheme self.login_view_class = login_view_class or self.login_view_class self.admin_title = admin_title or self.admin_title + if default_list_sticky_footer is not None: + self.default_list_sticky_footer = default_list_sticky_footer def init_registered_views(self): registered_views = [] diff --git a/src/django_smartbase_admin/static/sb_admin/src/css/_tabulator.css b/src/django_smartbase_admin/static/sb_admin/src/css/_tabulator.css index fdebaf2..a613776 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/css/_tabulator.css +++ b/src/django_smartbase_admin/static/sb_admin/src/css/_tabulator.css @@ -795,11 +795,44 @@ } .tabulator-custom-footer { - @apply flex p-16; @apply border-t border-dark-200; @apply text-14; } +.tabulator-custom-footer__inner { + @apply flex p-16; +} + +.tabulator-custom-footer--sticky { + position: sticky; + bottom: 0; + left: 0; + z-index: 10; + @apply bg-bg-elevated; +} + +.tabulator-sticky-scrollbar { + overflow-x: auto; + @apply border-b border-dark-200; + @extend .custom-scrollbar; +} + +.tabulator-sticky-scrollbar[data-no-overflow] { + display: none; +} + +.tabulator-sticky-scrollbar__spacer { + height: 1px; +} + +.tabulator-tableholder--sticky-footer::-webkit-scrollbar:horizontal { + display: none; +} + +.tabulator-tableholder--sticky-footer { + scrollbar-width: none; +} + .tabulator-responsive-collapse table { @apply gap-8; @apply flex; diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/table.js b/src/django_smartbase_admin/static/sb_admin/src/js/table.js index 12f252d..8f75fd7 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/js/table.js +++ b/src/django_smartbase_admin/static/sb_admin/src/js/table.js @@ -10,6 +10,7 @@ import {MovableColumnsModule} from "./table_modules/movable_columns_module" import {DataEditModule} from "./table_modules/data_edit_module" import {FullTextSearchModule} from "./table_modules/full_text_search_module" import { HeaderTabsModule } from "./table_modules/header_tabs_module" +import { StickyFooterModule } from "./table_modules/sticky_footer_module" import { SBAjaxParamsTabulatorModifier } from "./sb_ajax_params_tabulator_modifier" @@ -380,4 +381,5 @@ window.SBAdminTableModulesClass = { 'dataEditModule': DataEditModule, 'fullTextSearchModule': FullTextSearchModule, 'headerTabsModule': HeaderTabsModule, + 'stickyFooterModule': StickyFooterModule, } diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/sticky_footer_module.js b/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/sticky_footer_module.js new file mode 100644 index 0000000..1367063 --- /dev/null +++ b/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/sticky_footer_module.js @@ -0,0 +1,60 @@ +import { SBAdminTableModule } from "./base_module" + + +export class StickyFooterModule extends SBAdminTableModule { + + afterInit() { + const scrollbar = document.querySelector( + `[data-sticky-scrollbar="${this.table.viewId}"]` + ) + if (!scrollbar) { + console.warn(`[StickyFooterModule] sticky scrollbar element missing for viewId: ${this.table.viewId}`) + return + } + const spacer = scrollbar.firstElementChild + const tableEl = this.table.tabulator.element + const tableholder = tableEl.querySelector(".tabulator-tableholder") + if (!tableholder || !spacer) { + console.warn(`[StickyFooterModule] tableholder or spacer missing for viewId: ${this.table.viewId}`) + return + } + + tableholder.classList.add("tabulator-tableholder--sticky-footer") + + const syncWidth = () => { + const contentWidth = tableholder.scrollWidth + spacer.style.width = `${contentWidth}px` + // +1 absorbs sub-pixel rounding that would otherwise flag a non-overflowing table as overflowing + const overflows = contentWidth > tableholder.clientWidth + 1 + scrollbar.toggleAttribute("data-no-overflow", !overflows) + if (!overflows) { + scrollbar.scrollLeft = 0 + } + } + + tableholder.addEventListener("scroll", () => { + if (scrollbar.scrollLeft !== tableholder.scrollLeft) { + scrollbar.scrollLeft = tableholder.scrollLeft + } + }, { passive: true }) + + scrollbar.addEventListener("scroll", () => { + if (tableholder.scrollLeft !== scrollbar.scrollLeft) { + tableholder.scrollLeft = scrollbar.scrollLeft + } + }, { passive: true }) + + const resizeObserver = new ResizeObserver(syncWidth) + resizeObserver.observe(tableholder) + const innerTable = tableholder.querySelector(".tabulator-table") + if (innerTable) { + resizeObserver.observe(innerTable) + } + + this.table.tabulator.on("dataProcessed", syncWidth) + this.table.tabulator.on("columnResized", syncWidth) + this.table.tabulator.on("columnVisibilityChanged", syncWidth) + + syncWidth() + } +} diff --git a/src/django_smartbase_admin/templates/sb_admin/actions/list.html b/src/django_smartbase_admin/templates/sb_admin/actions/list.html index 9ad99c8..a524204 100644 --- a/src/django_smartbase_admin/templates/sb_admin/actions/list.html +++ b/src/django_smartbase_admin/templates/sb_admin/actions/list.html @@ -105,12 +105,19 @@

    {% endblock %} {% block tabulator_custom_footer %} -