Skip to content

Commit ee8ea67

Browse files
authored
triv: added blocks to be able to add own html (#129)
* triv: added blocks to be able to add own html * fix: removed field_cache to be able to do request-based sb_admin_list * feat: added possible to add optgroup in choice filter * feat: added the possibility to stick footer on tables with sticky scrollbar * fix: added translations in confirm modal and fixed ui bug * fix: added translations in confirm modal and fixed ui bug * triv: ux auto hide dropdown when radio * feat: added styling for treebwidget, fixed jump on click and dont show expand when no children presented * feat: added styling for treebwidget, fixed jump on click and dont show expand when no children presented * feat: implement request-based field caching in SBAdminBaseView * triv: enable sticky by default * triv: black
1 parent 3f051b7 commit ee8ea67

24 files changed

Lines changed: 524 additions & 180 deletions

AGENTS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,26 @@ Styles live in `static/sb_admin/src/css/_tabulator.css`: the icon is hidden on s
878878

879879
> **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.
880880
881+
### Grouped Choices
882+
883+
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.
884+
885+
```python
886+
# Flat (unchanged)
887+
MultipleChoiceFilterWidget(choices=[
888+
("draft", "Draft"),
889+
("published", "Published"),
890+
])
891+
892+
# Grouped — shipper is the group header
893+
MultipleChoiceFilterWidget(choices=[
894+
("GLS", [("1", "Insurance"), ("2", "Signature required")]),
895+
("SPS", [("5", "Special handling"), ("6", "Overweight")]),
896+
])
897+
```
898+
899+
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.
900+
881901
### Custom Filter Widget Example
882902

883903
```python
@@ -3312,6 +3332,7 @@ Quick reference for all `sbadmin_` prefixed class attributes available in `SBAdm
33123332
| `sbadmin_list_reorder_field` | str | Field name for drag-and-drop row reordering |
33133333
| `sbadmin_xlsx_options` | dict | Excel export configuration options |
33143334
| `sbadmin_table_history_enabled` | bool | Enable/disable table state history (default: `True`) |
3335+
| `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. |
33153336

33163337
### Detail/Change View Attributes (SBAdmin)
33173338

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ MIDDLEWARE = [
120120
]
121121
```
122122

123+
Enable Django i18n URLs so `{% url 'set_language' %}` is available for the navigation language picker:
124+
```python
125+
from django.urls import include, path
126+
127+
urlpatterns = [
128+
path("i18n/", include("django.conf.urls.i18n")),
129+
]
130+
```
131+
123132
## 🔍 Audit Logging
124133

125134
Built-in optional audit app that automatically tracks all admin create, update, delete, and bulk operations with field-level diffs, snapshots, and request grouping. Just add `"django_smartbase_admin.audit"` to `INSTALLED_APPS` and run migrations.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-smartbase-admin"
3-
version = "1.1.8"
3+
version = "1.1.7b1"
44
description = ""
55
authors = ["SmartBase <info@smartbase.sk>"]
66
readme = "README.md"

src/django_smartbase_admin/engine/admin_base_view.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,21 @@ def get_field_map(self, request) -> dict[str, "SBAdminField"]:
151151
return self.field_cache
152152

153153
def init_fields_cache(self, fields_source, configuration, force=False):
154-
if not force and self.field_cache:
155-
return self.field_cache.values()
156154
from django_smartbase_admin.engine.field import SBAdminField
155+
from django_smartbase_admin.services.thread_local import (
156+
SBAdminThreadLocalService,
157+
)
158+
159+
try:
160+
request = SBAdminThreadLocalService.get_request()
161+
except LookupError:
162+
request = None
163+
cache_key = self.get_id()
164+
if request is not None:
165+
request_field_cache = getattr(request, "_sbadmin_field_cache", {})
166+
if not force and cache_key in request_field_cache:
167+
self.field_cache = request_field_cache[cache_key]
168+
return self.field_cache.values()
157169

158170
fields = []
159171
self.field_cache = {}
@@ -163,6 +175,10 @@ def init_fields_cache(self, fields_source, configuration, force=False):
163175
field.init_field_static(self, configuration)
164176
fields.append(field)
165177
self.field_cache[field.name] = field
178+
if request is not None:
179+
request_field_cache = getattr(request, "_sbadmin_field_cache", {})
180+
request_field_cache[cache_key] = self.field_cache
181+
request._sbadmin_field_cache = request_field_cache
166182
return fields
167183

168184
def get_action_url(self, action, modifier="template"):
@@ -217,6 +233,13 @@ def get_color_scheme_context(self, request):
217233
"color_scheme_form": color_scheme_form,
218234
}
219235

236+
def get_language_form_context(self, request):
237+
if len(settings.LANGUAGES) <= 1:
238+
return {"language_form": None}
239+
from django_smartbase_admin.views.user_config_view import LanguageForm
240+
241+
return {"language_form": LanguageForm(request=request)}
242+
220243
def get_add_label(
221244
self, request: HttpRequest, object_id: str | None = None
222245
) -> str | None:
@@ -271,6 +294,7 @@ def get_global_context(
271294
}
272295
),
273296
**self.get_color_scheme_context(request),
297+
**self.get_language_form_context(request),
274298
}
275299

276300
def get_model_path(self) -> str:
@@ -319,6 +343,7 @@ class SBAdminBaseListView(SBAdminBaseView):
319343
sbadmin_list_history_enabled = True
320344
sbadmin_list_reorder_field = None
321345
sbadmin_nested: dict | None = None
346+
sbadmin_list_sticky_header_and_footer = None
322347
search_field_placeholder = _("Search...")
323348
filters_version = None
324349
sbadmin_actions_initialized = False
@@ -490,8 +515,16 @@ def has_add_permission(self, request, obj=None) -> bool:
490515
return False
491516
return super().has_add_permission(request)
492517

518+
def get_sbadmin_list_sticky_header_and_footer(self, request) -> bool:
519+
if self.sbadmin_list_sticky_header_and_footer is not None:
520+
return self.sbadmin_list_sticky_header_and_footer
521+
return request.request_data.configuration.default_list_sticky_header_and_footer
522+
493523
def get_tabulator_definition(self, request) -> dict[str, Any]:
494524
view_id = self.get_id()
525+
sticky_header_and_footer = self.get_sbadmin_list_sticky_header_and_footer(
526+
request
527+
)
495528
tabulator_definition = {
496529
"viewId": view_id,
497530
"advancedFilterId": f"{view_id}" + "-advanced-filter",
@@ -510,6 +543,7 @@ def get_tabulator_definition(self, request) -> dict[str, Any]:
510543
"tableInitialSort": self.get_list_initial_order(request),
511544
"tableInitialPageSize": self.get_list_per_page(request),
512545
"tableHistoryEnabled": self.sbadmin_table_history_enabled,
546+
"stickyHeaderAndFooter": sticky_header_and_footer,
513547
# used to initialize all columns with these values
514548
"defaultColumnData": {},
515549
"locale": request.LANGUAGE_CODE,
@@ -520,6 +554,7 @@ def get_tabulator_definition(self, request) -> dict[str, Any]:
520554
"filterModule",
521555
"tableParamsModule",
522556
"detailViewModule",
557+
"dataTreeModule",
523558
],
524559
"tabulatorOptions": {
525560
"renderVertical": "basic",
@@ -554,6 +589,8 @@ def get_tabulator_definition(self, request) -> dict[str, Any]:
554589
request=request,
555590
definition=tabulator_definition,
556591
)
592+
if sticky_header_and_footer:
593+
tabulator_definition["modules"].append("stickyHeaderAndFooterModule")
557594
return tabulator_definition
558595

559596
def _get_sbadmin_list_actions(self, request) -> list[SBAdminCustomAction] | list:
@@ -777,7 +814,11 @@ def get_all_config(self, request) -> dict[str, Any]:
777814
if not list_filter:
778815
return all_config
779816
list_fields = self.get_sbadmin_list_display(request) or []
780-
self.init_fields_cache(list_fields, request.request_data.configuration)
817+
initialized_fields = self.init_fields_cache(
818+
list_fields, request.request_data.configuration
819+
)
820+
if initialized_fields is not None:
821+
list_fields = initialized_fields
781822
base_filter = {
782823
getattr(field, "filter_field", field): ""
783824
for field in list_fields

src/django_smartbase_admin/engine/configuration.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ class SBAdminRoleConfiguration(metaclass=Singleton):
192192
# ``plugins/base.py`` for the protocol. Each plugin hook is expected
193193
# to self-guard based on admin config (e.g. ``sbadmin_nested``).
194194
plugins: list = []
195+
default_list_sticky_header_and_footer = True
195196

196197
def __init__(
197198
self,
@@ -204,6 +205,7 @@ def __init__(
204205
login_view_class=None,
205206
admin_title=None,
206207
plugins=None,
208+
default_list_sticky_header_and_footer=None,
207209
) -> None:
208210
super().__init__()
209211
self.default_view = default_view or self.default_view or []
@@ -219,6 +221,10 @@ def __init__(
219221
# Copy the class-level list to avoid accidental cross-instance
220222
# mutation when subclasses assign ``plugins = [...]``.
221223
self.plugins = list(plugins if plugins is not None else self.plugins)
224+
if default_list_sticky_header_and_footer is not None:
225+
self.default_list_sticky_header_and_footer = (
226+
default_list_sticky_header_and_footer
227+
)
222228

223229
def init_registered_views(self):
224230
registered_views = []

src/django_smartbase_admin/engine/filter_widgets.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,13 +257,45 @@ def __init__(
257257
)
258258
self.choices = self.choices or choices
259259

260+
@property
261+
def grouped_choices(self):
262+
"""Normalise ``choices`` into ``[(group_label_or_None, [(value, label), ...])]``.
263+
264+
Accepts flat ``[(value, label), ...]`` and Django-style grouped
265+
``[(group_label, [(value, label), ...]), ...]``. Flat input becomes a
266+
single ``None``-labelled group so templates iterate uniformly and skip
267+
the header when ``group_label`` is falsy. Mirrors the detection
268+
``ChoiceWidget.optgroups`` uses internally.
269+
"""
270+
if not self.choices:
271+
return []
272+
items = list(self.choices)
273+
first = items[0]
274+
is_grouped = (
275+
isinstance(first, (list, tuple))
276+
and len(first) == 2
277+
and isinstance(first[1], (list, tuple))
278+
)
279+
if is_grouped:
280+
return [(group_label, list(options)) for group_label, options in items]
281+
return [(None, items)]
282+
283+
@property
284+
def flat_choices(self):
285+
"""Flat ``[(value, label), ...]`` view of ``choices`` — same list for
286+
both flat and grouped input. Use this for label lookup."""
287+
flat = []
288+
for _, options in self.grouped_choices:
289+
flat.extend(options)
290+
return flat
291+
260292
def get_default_label(self):
261293
if self.default_label:
262294
return self.default_label
263295
else:
264296
default_value = self.get_default_value()
265297
found_label = [
266-
label for value, label in self.choices if value == default_value
298+
label for value, label in self.flat_choices if value == default_value
267299
]
268300
return found_label[0] if found_label else default_value
269301

src/django_smartbase_admin/plugins/nested.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
CHILDREN_FIELD = "_children"
6363
PARENT_REAL_ID = "parent_real_id"
6464
CHILDREN_IDS = "children_ids"
65+
LAST_CHILD_FIELD = "_sbadmin_tree_last_child"
6566

6667
_KNOWN_KEYS = {
6768
"parent_field",
@@ -126,6 +127,7 @@ def modify_tabulator_definition(
126127
"dataTree": True,
127128
"dataTreeChildField": CHILDREN_FIELD,
128129
"dataTreeStartExpanded": nested.get("start_expanded", False),
130+
"sbadminTreeLastChildField": LAST_CHILD_FIELD,
129131
}
130132
if element_column:
131133
options["dataTreeElementColumn"] = element_column
@@ -260,7 +262,12 @@ def modify_final_data(
260262
root_row = by_id.get(root_id)
261263
if root_row is None:
262264
continue
263-
root_row[CHILDREN_FIELD] = children_by_parent.get(root_id, [])
265+
children = children_by_parent.get(root_id)
266+
if children:
267+
children[-1][LAST_CHILD_FIELD] = True
268+
root_row[CHILDREN_FIELD] = children
269+
else:
270+
root_row.pop(CHILDREN_FIELD, None)
264271
result.append(root_row)
265272
return result
266273

@@ -285,6 +292,9 @@ def modify_xlsx_data(
285292
flattened: list[dict[str, Any]] = []
286293
for row in data:
287294
children = row.pop(CHILDREN_FIELD, None) or []
295+
row.pop(LAST_CHILD_FIELD, None)
296+
if children:
297+
children[-1].pop(LAST_CHILD_FIELD, None)
288298
flattened.append(row)
289299
flattened.extend(children)
290300
return flattened

src/django_smartbase_admin/plugins/tests/test_nested.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from django_smartbase_admin.admin.site import sb_admin_site
3131
from django_smartbase_admin.engine.request import SBAdminViewRequestData
3232
from django_smartbase_admin.plugins.nested import (
33+
LAST_CHILD_FIELD,
3334
TabulatorNestedPlugin,
3435
resolve_nested,
3536
)
@@ -155,6 +156,7 @@ def test_tabulator_definition_enables_data_tree(self):
155156
self.assertTrue(opts["dataTree"])
156157
self.assertEqual(opts["dataTreeChildField"], "_children")
157158
self.assertEqual(opts["dataTreeElementColumn"], "id")
159+
self.assertEqual(opts["sbadminTreeLastChildField"], LAST_CHILD_FIELD)
158160

159161
view, request = self._make_view_and_request(sbadmin_nested=None)
160162
opts = view.get_tabulator_definition(request)["tabulatorOptions"]
@@ -251,6 +253,9 @@ def test_action_list_json_preserves_child_order_within_group(self):
251253
[child["name"] for child in payload["data"][0]["_children"]],
252254
["a_child", "z_child"],
253255
)
256+
self.assertNotIn(LAST_CHILD_FIELD, payload["data"][0])
257+
self.assertNotIn(LAST_CHILD_FIELD, payload["data"][0]["_children"][0])
258+
self.assertTrue(payload["data"][0]["_children"][1][LAST_CHILD_FIELD])
254259

255260
@postgres_only
256261
def test_only_show_filtered_children_false_shows_all_direct_children(self):

0 commit comments

Comments
 (0)