diff --git a/AGENTS.md b/AGENTS.md index b692c1e3..4c29034d 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_header_and_footer` | bool \| None | Enable sticky Tabulator column header together with sticky pagination footer and synced horizontal scrollbar. `None` falls back to `SBAdminRoleConfiguration.default_list_sticky_header_and_footer`; explicit `True`/`False` overrides the global setting. | ### Detail/Change View Attributes (SBAdmin) diff --git a/pyproject.toml b/pyproject.toml index e8a8262b..bfd16e5e 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/engine/admin_base_view.py b/src/django_smartbase_admin/engine/admin_base_view.py index 5f809d2a..4b050515 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_header_and_footer = None search_field_placeholder = _("Search...") filters_version = None sbadmin_actions_initialized = False @@ -480,8 +479,16 @@ def has_add_permission(self, request, obj=None) -> bool: return False return super().has_add_permission(request) + def get_sbadmin_list_sticky_header_and_footer(self, request) -> bool: + if self.sbadmin_list_sticky_header_and_footer is not None: + return self.sbadmin_list_sticky_header_and_footer + return request.request_data.configuration.default_list_sticky_header_and_footer + def get_tabulator_definition(self, request) -> dict[str, Any]: view_id = self.get_id() + sticky_header_and_footer = self.get_sbadmin_list_sticky_header_and_footer( + request + ) tabulator_definition = { "viewId": view_id, "advancedFilterId": f"{view_id}" + "-advanced-filter", @@ -500,6 +507,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, + "stickyHeaderAndFooter": sticky_header_and_footer, # used to initialize all columns with these values "defaultColumnData": {}, "locale": request.LANGUAGE_CODE, @@ -538,6 +546,8 @@ def get_tabulator_definition(self, request) -> dict[str, Any]: "headerTabsModule", ] ) + if sticky_header_and_footer: + tabulator_definition["modules"].append("stickyHeaderAndFooterModule") return tabulator_definition def _get_sbadmin_list_actions(self, request) -> list[SBAdminCustomAction] | list: @@ -761,7 +771,11 @@ 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) + initialized_fields = self.init_fields_cache( + list_fields, request.request_data.configuration + ) + if initialized_fields is not None: + list_fields = initialized_fields base_filter = { getattr(field, "filter_field", field): "" for field in list_fields diff --git a/src/django_smartbase_admin/engine/configuration.py b/src/django_smartbase_admin/engine/configuration.py index e0a840a3..e27e4d59 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_header_and_footer = True def __init__( self, @@ -198,6 +199,7 @@ def __init__( default_color_scheme=None, login_view_class=None, admin_title=None, + default_list_sticky_header_and_footer=None, ) -> None: super().__init__() self.default_view = default_view or self.default_view or [] @@ -210,6 +212,10 @@ 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_header_and_footer is not None: + self.default_list_sticky_header_and_footer = ( + default_list_sticky_header_and_footer + ) def init_registered_views(self): registered_views = [] diff --git a/src/django_smartbase_admin/engine/filter_widgets.py b/src/django_smartbase_admin/engine/filter_widgets.py index d378be22..eebd4e2f 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/static/sb_admin/src/css/_tabulator.css b/src/django_smartbase_admin/static/sb_admin/src/css/_tabulator.css index fdebaf2b..a1c5e077 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 @@ -1,7 +1,7 @@ .tabulator { position: relative; text-align: left; - overflow: hidden; + overflow: visible; transform: translateZ(0); @apply text-14; } @@ -28,6 +28,12 @@ @apply border-b border-dark-200 bg-dark-50 text-dark-600; } +.tabulator.tabulator--sticky-header-and-footer .tabulator-header { + position: sticky; + top: 0; + z-index: 10; +} + .tabulator .tabulator-header.tabulator-header-hidden { display: none; } @@ -795,11 +801,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/confirmation_modal.js b/src/django_smartbase_admin/static/sb_admin/src/js/confirmation_modal.js index 2510187b..5162de37 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/js/confirmation_modal.js +++ b/src/django_smartbase_admin/static/sb_admin/src/js/confirmation_modal.js @@ -17,13 +17,14 @@ export class Confirmation { } this.modal = new window.bootstrap5.Modal(this.modalEl) + const translations = window.sb_admin_translation_strings || {} this.defaultModalData = { 'responseTarget': 'body', 'confirmBody': null, 'confirmIcon': null, 'confirmFooter': null, - 'confirmSubmit': 'Confirm', - 'confirmClose': 'Cancel', + 'confirmSubmit': translations['confirm'] || 'Confirm', + 'confirmClose': translations['cancel'] || 'Cancel', 'submitEvent': 'confirm', 'cancelEvent': 'cancel', } 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 12f252d2..1b181e6e 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 { StickyHeaderAndFooterModule } from "./table_modules/sticky_header_and_footer_module" import { SBAjaxParamsTabulatorModifier } from "./sb_ajax_params_tabulator_modifier" @@ -380,4 +381,5 @@ window.SBAdminTableModulesClass = { 'dataEditModule': DataEditModule, 'fullTextSearchModule': FullTextSearchModule, 'headerTabsModule': HeaderTabsModule, + 'stickyHeaderAndFooterModule': StickyHeaderAndFooterModule, } diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/sticky_header_and_footer_module.js b/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/sticky_header_and_footer_module.js new file mode 100644 index 00000000..4d18331a --- /dev/null +++ b/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/sticky_header_and_footer_module.js @@ -0,0 +1,62 @@ +import { SBAdminTableModule } from "./base_module" + + +export class StickyHeaderAndFooterModule extends SBAdminTableModule { + + afterInit() { + const tableEl = this.table.tabulator.element + tableEl.classList.add("tabulator--sticky-header-and-footer") + + const scrollbar = document.querySelector( + `[data-sticky-scrollbar="${this.table.viewId}"]` + ) + if (!scrollbar) { + console.warn(`[StickyHeaderAndFooterModule] sticky scrollbar element missing for viewId: ${this.table.viewId}`) + return + } + const spacer = scrollbar.firstElementChild + const tableholder = tableEl.querySelector(".tabulator-tableholder") + if (!tableholder || !spacer) { + console.warn(`[StickyHeaderAndFooterModule] 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 9ad99c8d..43240370 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 %} - \ No newline at end of file + 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 16a787a3..84057418 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 d5fd0b01..9859775a 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 0eae2e5e..cb960dac 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 3bb85cc8..1c75f0f2 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 %} diff --git a/src/django_smartbase_admin/templates/sb_admin/navigation.html b/src/django_smartbase_admin/templates/sb_admin/navigation.html index 3be5b3e1..5c112d1f 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 %}
    + {% block after_navigation %}{% endblock %} {% endif %} {% endblock %} diff --git a/src/django_smartbase_admin/templates/sb_admin/sb_admin_js_trans.html b/src/django_smartbase_admin/templates/sb_admin/sb_admin_js_trans.html index e8616169..d5d23923 100644 --- a/src/django_smartbase_admin/templates/sb_admin/sb_admin_js_trans.html +++ b/src/django_smartbase_admin/templates/sb_admin/sb_admin_js_trans.html @@ -17,4 +17,6 @@ window.sb_admin_translation_strings["page"] = '{% blocktrans %}${from} - ${to} of ${total} items{% endblocktrans %}' window.sb_admin_translation_strings["page_empty"] = '{% blocktrans %}0 items{% endblocktrans %}' window.sb_admin_translation_strings["selected"] = '{% blocktrans %}${value} selected{% endblocktrans %}' + window.sb_admin_translation_strings["confirm"] = '{% trans "Confirm" %}'; + window.sb_admin_translation_strings["cancel"] = '{% trans "Cancel" %}'; \ No newline at end of file