Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<optgroup>` in select templates, a styled header `<li>` 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
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "django-smartbase-admin"
version = "1.1.7"
version = "1.1.7b1"
description = ""
authors = ["SmartBase <info@smartbase.sk>"]
readme = "README.md"
Expand Down
20 changes: 17 additions & 3 deletions src/django_smartbase_admin/engine/admin_base_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/django_smartbase_admin/engine/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 []
Expand All @@ -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 = []
Expand Down
34 changes: 33 additions & 1 deletion src/django_smartbase_admin/engine/filter_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 41 additions & 2 deletions src/django_smartbase_admin/static/sb_admin/src/css/_tabulator.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.tabulator {
position: relative;
text-align: left;
overflow: hidden;
overflow: visible;
transform: translateZ(0);
@apply text-14;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Expand Down
2 changes: 2 additions & 0 deletions src/django_smartbase_admin/static/sb_admin/src/js/table.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -380,4 +381,5 @@ window.SBAdminTableModulesClass = {
'dataEditModule': DataEditModule,
'fullTextSearchModule': FullTextSearchModule,
'headerTabsModule': HeaderTabsModule,
'stickyHeaderAndFooterModule': StickyHeaderAndFooterModule,
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
17 changes: 12 additions & 5 deletions src/django_smartbase_admin/templates/sb_admin/actions/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,19 @@ <h1 class="text-24 md:text-30 text-dark-900 font-bold font-heading line-clamp-1
<div id="{{ view_id }}-table" class="list-view-table"></div>
{% endblock %}
{% block tabulator_custom_footer %}
<div class="tabulator-custom-footer">
<div class="flex items-center">
<div class="mr-16 max-sm:hidden">{% trans 'For page' %}</div>
<div id="{{ view_id }}-page-size-widget"></div>
<div class="tabulator-custom-footer{% if content_context.tabulator_definition.stickyHeaderAndFooter %} tabulator-custom-footer--sticky{% endif %}">
{% if content_context.tabulator_definition.stickyHeaderAndFooter %}
<div class="tabulator-sticky-scrollbar" data-sticky-scrollbar="{{ view_id }}">
<div class="tabulator-sticky-scrollbar__spacer"></div>
</div>
{% endif %}
<div class="tabulator-custom-footer__inner">
<div class="flex items-center">
<div class="mr-16 max-sm:hidden">{% trans 'For page' %}</div>
<div id="{{ view_id }}-page-size-widget"></div>
</div>
<div id="{{ view_id }}-pagination-widget" class="ml-auto flex items-center"></div>
</div>
<div id="{{ view_id }}-pagination-widget" class="ml-auto flex items-center"></div>
</div>
{% endblock %}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@
{% endwith %}
{% endblock %}
{% include 'sb_admin/actions/partials/selected_rows_actions.html' %}
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
id="{{ filter_widget.input_id }}"
name="{{ filter_widget.input_name }}"
>
{% for choice in filter_widget.choices %}
<option
value="{{ choice.0 }}"
>
{{ choice.1 }}
</option>
{% for group_label, group_choices in filter_widget.grouped_choices %}
{% if group_label %}<optgroup label="{{ group_label }}">{% endif %}
{% for choice in group_choices %}
<option
value="{{ choice.0 }}"
>
{{ choice.1 }}
</option>
{% endfor %}
{% if group_label %}</optgroup>{% endif %}
{% endfor %}
</select>
Loading
Loading