diff --git a/AGENTS.md b/AGENTS.md index 3dab8986..47db8d1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -878,6 +878,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 @@ -3312,6 +3332,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/README.md b/README.md index dbc73d48..40613ce5 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,15 @@ MIDDLEWARE = [ ] ``` +Enable Django i18n URLs so `{% url 'set_language' %}` is available for the navigation language picker: +```python +from django.urls import include, path + +urlpatterns = [ + path("i18n/", include("django.conf.urls.i18n")), +] +``` + ## 🔍 Audit Logging 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. diff --git a/pyproject.toml b/pyproject.toml index 1d9c4887..bfd16e5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-smartbase-admin" -version = "1.1.8" +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 02fd0054..2783e9d6 100644 --- a/src/django_smartbase_admin/engine/admin_base_view.py +++ b/src/django_smartbase_admin/engine/admin_base_view.py @@ -151,9 +151,21 @@ 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 + from django_smartbase_admin.services.thread_local import ( + SBAdminThreadLocalService, + ) + + try: + request = SBAdminThreadLocalService.get_request() + except LookupError: + request = None + cache_key = self.get_id() + if request is not None: + request_field_cache = getattr(request, "_sbadmin_field_cache", {}) + if not force and cache_key in request_field_cache: + self.field_cache = request_field_cache[cache_key] + return self.field_cache.values() fields = [] self.field_cache = {} @@ -163,6 +175,10 @@ def init_fields_cache(self, fields_source, configuration, force=False): field.init_field_static(self, configuration) fields.append(field) self.field_cache[field.name] = field + if request is not None: + request_field_cache = getattr(request, "_sbadmin_field_cache", {}) + request_field_cache[cache_key] = self.field_cache + request._sbadmin_field_cache = request_field_cache return fields def get_action_url(self, action, modifier="template"): @@ -217,6 +233,13 @@ def get_color_scheme_context(self, request): "color_scheme_form": color_scheme_form, } + def get_language_form_context(self, request): + if len(settings.LANGUAGES) <= 1: + return {"language_form": None} + from django_smartbase_admin.views.user_config_view import LanguageForm + + return {"language_form": LanguageForm(request=request)} + def get_add_label( self, request: HttpRequest, object_id: str | None = None ) -> str | None: @@ -271,6 +294,7 @@ def get_global_context( } ), **self.get_color_scheme_context(request), + **self.get_language_form_context(request), } def get_model_path(self) -> str: @@ -319,6 +343,7 @@ class SBAdminBaseListView(SBAdminBaseView): sbadmin_list_history_enabled = True sbadmin_list_reorder_field = None sbadmin_nested: dict | None = None + sbadmin_list_sticky_header_and_footer = None search_field_placeholder = _("Search...") filters_version = None sbadmin_actions_initialized = False @@ -490,8 +515,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", @@ -510,6 +543,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, @@ -520,6 +554,7 @@ def get_tabulator_definition(self, request) -> dict[str, Any]: "filterModule", "tableParamsModule", "detailViewModule", + "dataTreeModule", ], "tabulatorOptions": { "renderVertical": "basic", @@ -554,6 +589,8 @@ def get_tabulator_definition(self, request) -> dict[str, Any]: request=request, definition=tabulator_definition, ) + if sticky_header_and_footer: + tabulator_definition["modules"].append("stickyHeaderAndFooterModule") return tabulator_definition def _get_sbadmin_list_actions(self, request) -> list[SBAdminCustomAction] | list: @@ -777,7 +814,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 610abf7a..1793c673 100644 --- a/src/django_smartbase_admin/engine/configuration.py +++ b/src/django_smartbase_admin/engine/configuration.py @@ -192,6 +192,7 @@ class SBAdminRoleConfiguration(metaclass=Singleton): # ``plugins/base.py`` for the protocol. Each plugin hook is expected # to self-guard based on admin config (e.g. ``sbadmin_nested``). plugins: list = [] + default_list_sticky_header_and_footer = True def __init__( self, @@ -204,6 +205,7 @@ def __init__( login_view_class=None, admin_title=None, plugins=None, + default_list_sticky_header_and_footer=None, ) -> None: super().__init__() self.default_view = default_view or self.default_view or [] @@ -219,6 +221,10 @@ def __init__( # Copy the class-level list to avoid accidental cross-instance # mutation when subclasses assign ``plugins = [...]``. self.plugins = list(plugins if plugins is not None else self.plugins) + 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/plugins/nested.py b/src/django_smartbase_admin/plugins/nested.py index 18a4967d..9d46d385 100644 --- a/src/django_smartbase_admin/plugins/nested.py +++ b/src/django_smartbase_admin/plugins/nested.py @@ -62,6 +62,7 @@ CHILDREN_FIELD = "_children" PARENT_REAL_ID = "parent_real_id" CHILDREN_IDS = "children_ids" +LAST_CHILD_FIELD = "_sbadmin_tree_last_child" _KNOWN_KEYS = { "parent_field", @@ -126,6 +127,7 @@ def modify_tabulator_definition( "dataTree": True, "dataTreeChildField": CHILDREN_FIELD, "dataTreeStartExpanded": nested.get("start_expanded", False), + "sbadminTreeLastChildField": LAST_CHILD_FIELD, } if element_column: options["dataTreeElementColumn"] = element_column @@ -260,7 +262,12 @@ def modify_final_data( root_row = by_id.get(root_id) if root_row is None: continue - root_row[CHILDREN_FIELD] = children_by_parent.get(root_id, []) + children = children_by_parent.get(root_id) + if children: + children[-1][LAST_CHILD_FIELD] = True + root_row[CHILDREN_FIELD] = children + else: + root_row.pop(CHILDREN_FIELD, None) result.append(root_row) return result @@ -285,6 +292,9 @@ def modify_xlsx_data( flattened: list[dict[str, Any]] = [] for row in data: children = row.pop(CHILDREN_FIELD, None) or [] + row.pop(LAST_CHILD_FIELD, None) + if children: + children[-1].pop(LAST_CHILD_FIELD, None) flattened.append(row) flattened.extend(children) return flattened diff --git a/src/django_smartbase_admin/plugins/tests/test_nested.py b/src/django_smartbase_admin/plugins/tests/test_nested.py index a1bcb5b3..9843823b 100644 --- a/src/django_smartbase_admin/plugins/tests/test_nested.py +++ b/src/django_smartbase_admin/plugins/tests/test_nested.py @@ -30,6 +30,7 @@ from django_smartbase_admin.admin.site import sb_admin_site from django_smartbase_admin.engine.request import SBAdminViewRequestData from django_smartbase_admin.plugins.nested import ( + LAST_CHILD_FIELD, TabulatorNestedPlugin, resolve_nested, ) @@ -155,6 +156,7 @@ def test_tabulator_definition_enables_data_tree(self): self.assertTrue(opts["dataTree"]) self.assertEqual(opts["dataTreeChildField"], "_children") self.assertEqual(opts["dataTreeElementColumn"], "id") + self.assertEqual(opts["sbadminTreeLastChildField"], LAST_CHILD_FIELD) view, request = self._make_view_and_request(sbadmin_nested=None) opts = view.get_tabulator_definition(request)["tabulatorOptions"] @@ -251,6 +253,9 @@ def test_action_list_json_preserves_child_order_within_group(self): [child["name"] for child in payload["data"][0]["_children"]], ["a_child", "z_child"], ) + self.assertNotIn(LAST_CHILD_FIELD, payload["data"][0]) + self.assertNotIn(LAST_CHILD_FIELD, payload["data"][0]["_children"][0]) + self.assertTrue(payload["data"][0]["_children"][1][LAST_CHILD_FIELD]) @postgres_only def test_only_show_filtered_children_false_shows_all_direct_children(self): 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..9482e3a0 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; } @@ -586,72 +592,93 @@ background: #666; } -.tabulator-row .tabulator-cell .tabulator-data-tree-branch { - display: inline-block; - vertical-align: middle; - height: 9px; - width: 7px; - margin-top: -9px; - margin-right: 5px; - border-bottom-left-radius: 1px; - border-left: 2px solid #aaa; - border-bottom: 2px solid #aaa; -} - -/* TODO tree */ -.tabulator-row .tabulator-cell .tabulator-data-tree-control { - display: inline-flex; - justify-content: center; - align-items: center; - vertical-align: middle; - height: 11px; - width: 11px; - margin-right: 5px; - border: 1px solid #333; - border-radius: 2px; - background: rgba(0, 0, 0, 0.1); - overflow: hidden; -} - -.tabulator-row .tabulator-cell .tabulator-data-tree-control:hover { - cursor: pointer; - background: rgba(0, 0, 0, 0.2); -} - -.tabulator-row .tabulator-cell .tabulator-data-tree-control .tabulator-data-tree-control-collapse { - display: inline-block; - position: relative; - height: 7px; - width: 1px; - background: transparent; -} - -.tabulator-row .tabulator-cell .tabulator-data-tree-control .tabulator-data-tree-control-collapse:after { - position: absolute; - content: ""; - left: -3px; - top: 3px; - height: 1px; - width: 7px; - background: #333; -} +.tabulator-row { + --tabulator-tree-cell-padding: -8px; + --tabulator-tree-branch-size: 40px; + --tabulator-tree-control-size: 40px; + + .tabulator-cell { + .tabulator-data-tree-branch { + --tabulator-tree-line-color: theme('colors.dark.400'); + position: relative; + display: inline-flex; + align-self: stretch; + flex: 0 0 var(--tabulator-tree-branch-size); + width: var(--tabulator-tree-branch-size); + margin-left: 0 !important; + + &:before, + &:after { + content: ""; + position: absolute; + display: block; + } + + &:before { + top: var(--tabulator-tree-cell-padding); + bottom: var(--tabulator-tree-cell-padding); + left: 50%; + border-left: 2px solid var(--tabulator-tree-line-color); + transform: translateX(-50%); + } + + &:after { + left: calc(50% - 1px); + right: 0; + top: 50%; + border-bottom: 2px solid var(--tabulator-tree-line-color); + } + } -.tabulator-row .tabulator-cell .tabulator-data-tree-control .tabulator-data-tree-control-expand { - display: inline-block; - position: relative; - height: 7px; - width: 1px; - background: #333; -} + .tabulator-data-tree-control { + --tabulator-tree-icon-size: 20px; + --tabulator-tree-collapse-icon: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1ZM3 12C3 7.02943 7.02943 3 12 3C16.9706 3 21 7.02943 21 12C21 16.9706 16.9706 21 12 21C7.02943 21 3 16.9706 3 12ZM8 13H16V11H8V13Z' fill='%236B7280'/%3E%3C/svg%3E"); + --tabulator-tree-expand-icon: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12ZM12 3C7.02943 3 3 7.02943 3 12C3 16.9706 7.02943 21 12 21C16.9706 21 21 16.9706 21 12C21 7.02943 16.9706 3 12 3ZM11 11V8H13V11H16V13H13V16H11V13H8V11H11Z' fill='%236B7280'/%3E%3C/svg%3E"); + display: inline-flex; + justify-content: center; + align-items: center; + width: var(--tabulator-tree-control-size); + height: var(--tabulator-tree-control-size); + overflow: hidden; + + &:hover { + cursor: pointer; + filter: brightness(0.5); + } + + .tabulator-data-tree-control-collapse, + .tabulator-data-tree-control-expand { + display: inline-block; + position: relative; + width: var(--tabulator-tree-icon-size); + height: var(--tabulator-tree-icon-size); + background: transparent; + + &:after { + position: absolute; + content: ""; + inset: 0; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + } + } + + .tabulator-data-tree-control-collapse:after { + background-image: var(--tabulator-tree-collapse-icon); + } + + .tabulator-data-tree-control-expand:after { + background-image: var(--tabulator-tree-expand-icon); + } + } + } -.tabulator-row .tabulator-cell .tabulator-data-tree-control .tabulator-data-tree-control-expand:after { - position: absolute; - content: ""; - left: -3px; - top: 3px; - height: 1px; - width: 7px; - background: #333; + &.tabulator-tree-child-last { + .tabulator-cell .tabulator-data-tree-branch:before { + bottom: 50%; + } + } } /* TODO group */ @@ -721,83 +748,49 @@ color: #d00; } -.tabulator-print-table .tabulator-data-tree-branch { - display: inline-block; - vertical-align: middle; - height: 9px; - width: 7px; - margin-top: -9px; - margin-right: 5px; - border-bottom-left-radius: 1px; - border-left: 2px solid #aaa; - border-bottom: 2px solid #aaa; +.tabulator-custom-header { + @apply border-b border-dark-200; + @apply text-14; + @apply relative; } -.tabulator-print-table .tabulator-data-tree-control { - display: inline-flex; - justify-content: center; - align-items: center; - vertical-align: middle; - height: 11px; - width: 11px; - margin-right: 5px; - border: 1px solid #333; - border-radius: 2px; - background: rgba(0, 0, 0, 0.1); - overflow: hidden; +.tabulator-custom-footer { + @apply border-t border-dark-200; + @apply text-14; } -.tabulator-print-table .tabulator-data-tree-control:hover { - cursor: pointer; - background: rgba(0, 0, 0, 0.2); +.tabulator-custom-footer__inner { + @apply flex p-16; } -.tabulator-print-table .tabulator-data-tree-control .tabulator-data-tree-control-collapse { - display: inline-block; - position: relative; - height: 7px; - width: 1px; - background: transparent; +.tabulator-custom-footer--sticky { + position: sticky; + bottom: 0; + left: 0; + z-index: 10; + @apply bg-bg-elevated; } -.tabulator-print-table .tabulator-data-tree-control .tabulator-data-tree-control-collapse:after { - position: absolute; - content: ""; - left: -3px; - top: 3px; - height: 1px; - width: 7px; - background: #333; +.tabulator-sticky-scrollbar { + overflow-x: auto; + @apply border-b border-dark-200; + @extend .custom-scrollbar; } -.tabulator-print-table .tabulator-data-tree-control .tabulator-data-tree-control-expand { - display: inline-block; - position: relative; - height: 7px; - width: 1px; - background: #333; +.tabulator-sticky-scrollbar[data-no-overflow] { + display: none; } -.tabulator-print-table .tabulator-data-tree-control .tabulator-data-tree-control-expand:after { - position: absolute; - content: ""; - left: -3px; - top: 3px; +.tabulator-sticky-scrollbar__spacer { height: 1px; - width: 7px; - background: #333; } -.tabulator-custom-header { - @apply border-b border-dark-200; - @apply text-14; - @apply relative; +.tabulator-tableholder--sticky-footer::-webkit-scrollbar:horizontal { + display: none; } -.tabulator-custom-footer { - @apply flex p-16; - @apply border-t border-dark-200; - @apply text-14; +.tabulator-tableholder--sticky-footer { + scrollbar-width: none; } .tabulator-responsive-collapse table { 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/main.js b/src/django_smartbase_admin/static/sb_admin/src/js/main.js index fbd15fed..19808029 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/js/main.js +++ b/src/django_smartbase_admin/static/sb_admin/src/js/main.js @@ -242,8 +242,11 @@ class Main { const dropdownWrapper = dropdownToggleEl.closest('.js-dropdown-wrapper') if(dropdownWrapper) { const dropdownLabelEl = dropdownWrapper.querySelector('.js-dropdown-label') - dropdown._menu.addEventListener('change', ()=>{ + dropdown._menu.addEventListener('change', (event)=>{ setDropdownLabel(dropdown._menu, dropdownLabelEl) + if(event.target.closest("input[type='radio']")) { + dropdown.hide() + } }) } return dropdown 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..3ddf2f8e 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,8 @@ 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 { DataTreeModule } from "./table_modules/data_tree_module" +import { StickyHeaderAndFooterModule } from "./table_modules/sticky_header_and_footer_module" import { SBAjaxParamsTabulatorModifier } from "./sb_ajax_params_tabulator_modifier" @@ -380,4 +382,6 @@ window.SBAdminTableModulesClass = { 'dataEditModule': DataEditModule, 'fullTextSearchModule': FullTextSearchModule, 'headerTabsModule': HeaderTabsModule, + 'dataTreeModule': DataTreeModule, + 'stickyHeaderAndFooterModule': StickyHeaderAndFooterModule, } diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/data_tree_module.js b/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/data_tree_module.js new file mode 100644 index 00000000..bb1da4a0 --- /dev/null +++ b/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/data_tree_module.js @@ -0,0 +1,53 @@ +import { SBAdminTableModule } from "./base_module" + + +export class DataTreeModule extends SBAdminTableModule { + + freezeTableHeight() { + this.table.tabulator.element.style.height = `${this.table.tabulator.element.offsetHeight}px` + } + + restoreTableHeight() { + requestAnimationFrame(() => { + this.table.tabulator.element.style.height = this.table.tabulator.options.height + }) + } + + modifyTabulatorOptions(tabulatorOptions) { + const lastChildField = tabulatorOptions['sbadminTreeLastChildField'] + if (!tabulatorOptions['dataTree'] || !lastChildField) { + return tabulatorOptions + } + const existingRowFormatter = tabulatorOptions['rowFormatter'] + tabulatorOptions['rowFormatter'] = (row) => { + row.getElement().classList.toggle( + 'tabulator-tree-child-last', + Boolean(row.getData()?.[lastChildField]), + ) + if (typeof existingRowFormatter === 'function') { + existingRowFormatter(row) + } + } + return tabulatorOptions + } + + afterInit() { + if (!this.table.tabulatorOptions['dataTree']) { + return + } + + this.table.tabulator.element.addEventListener('mousedown', (e) => { + if (!e.target.closest('.tabulator-data-tree-control')) { + return + } + this.freezeTableHeight() + }, true) + + const restoreHeight = () => { + this.restoreTableHeight() + } + + this.table.tabulator.on("dataTreeRowExpanded", restoreHeight) + this.table.tabulator.on("dataTreeRowCollapsed", restoreHeight) + } +} 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 6d4bad0c..55bb6f6f 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..1f9c251a 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 diff --git a/src/django_smartbase_admin/templates/sb_admin/widgets/radio_dropdown.html b/src/django_smartbase_admin/templates/sb_admin/widgets/radio_dropdown.html index 71735b4e..6f649a5a 100644 --- a/src/django_smartbase_admin/templates/sb_admin/widgets/radio_dropdown.html +++ b/src/django_smartbase_admin/templates/sb_admin/widgets/radio_dropdown.html @@ -7,7 +7,7 @@ data-bs-toggle="dropdown" aria-expanded="false" data-bs-offset="[0, 8]" - class="btn px-10 font-normal w-full border-dark-300" + class="btn px-10 font-normal w-full border-dark-300 {{ widget.attrs.button_class|default:'' }}" > {{ widget.form_field.empty_label }} diff --git a/src/django_smartbase_admin/views/user_config_view.py b/src/django_smartbase_admin/views/user_config_view.py index e0904953..2517076d 100644 --- a/src/django_smartbase_admin/views/user_config_view.py +++ b/src/django_smartbase_admin/views/user_config_view.py @@ -1,6 +1,10 @@ from django import forms +from django.conf import settings from django.http import HttpResponse +from django.templatetags.static import static +from django.utils import translation from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView from django_smartbase_admin.admin.admin_base import SBAdminBaseFormInit @@ -24,7 +28,9 @@ class Meta: model = SBAdminUserConfiguration fields = ("color_scheme",) widgets = { - "color_scheme": SBAdminRadioDropdownWidget(), + "color_scheme": SBAdminRadioDropdownWidget( + attrs={"button_class": "shadow-none"} + ), } required = [] @@ -52,3 +58,61 @@ def form_valid(self, form): sb_admin_user_config.color_scheme = instance.color_scheme sb_admin_user_config.save(update_fields=["color_scheme"]) return HttpResponse(status=200) + + +class LanguageForm(SBAdminBaseFormInit, forms.Form): + language = forms.ChoiceField( + label=_("Language"), + choices=(), + widget=SBAdminRadioDropdownWidget(attrs={"button_class": "shadow-none"}), + ) + next = forms.CharField(widget=forms.HiddenInput(), required=False) + + @staticmethod + def get_flag_static_path(lang_code: str) -> str: + base = lang_code.split("-", 1)[0] + return static(f"sb_admin/images/flags/{base}.png") + + @classmethod + def resolve_active_language_code(cls, languages: list[tuple[str, str]]) -> str: + codes = [code for code, _ in languages] + if not codes: + return translation.get_language() + + current = translation.get_language() + if current in codes: + return current + + current_base = current.split("-", 1)[0] + for code in codes: + if code == current_base or code.startswith(f"{current_base}-"): + return code + + default_language = settings.LANGUAGE_CODE + if default_language in codes: + return default_language + + return codes[0] + + def __init__(self, *args, **kwargs): + request = kwargs.pop("request", None) + super().__init__(*args, request=request, **kwargs) + choices_formatted = [] + for lang_code, lang_name in settings.LANGUAGES: + flag_src = self.get_flag_static_path(lang_code) + choice_label = format_html( + '' + '{}' + "{}", + flag_src, + lang_name, + lang_name, + ) + choices_formatted.append((lang_code, choice_label)) + self.fields["language"].choices = choices_formatted + + if request is not None and not self.is_bound: + self.fields["next"].initial = request.get_full_path() + self.fields["language"].initial = self.resolve_active_language_code( + list(settings.LANGUAGES) + )