From ab74ed529e639989dddddf66c951d87149c1bae9 Mon Sep 17 00:00:00 2001 From: Viliam Mihalik Date: Thu, 18 Jun 2026 17:42:48 +0200 Subject: [PATCH 1/7] feat(dashboard): add grouped AJAX support and enhance dashboard widget functionality Introduced grouped AJAX calls for dashboard widgets, enabling parent widgets to refresh data for multiple subwidgets in a single request. Added `SBAdminDashboardHtmlWidget` and `SBAdminDashboardGroupWidget` for managing grouped widgets efficiently. Updated related tests, templates, and documentation. --- AGENTS.md | 372 ++++++++++++++++++ pyproject.toml | 2 +- .../engine/dashboard.py | 85 +++- .../static/sb_admin/build/webpack.common.js | 1 + .../static/sb_admin/src/js/chart.js | 121 ++++-- .../static/sb_admin/src/js/dashboard_group.js | 109 +++++ .../sb_admin/dashboard/chart_widget.html | 18 +- .../sb_admin/dashboard/group_widget.html | 23 ++ .../sb_admin/dashboard/html_widget.html | 36 ++ .../tests/test_dashboard.py | 198 +++++++++- .../views/dashboard_view.py | 2 +- 11 files changed, 914 insertions(+), 53 deletions(-) create mode 100644 src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js create mode 100644 src/django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html create mode 100644 src/django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html diff --git a/AGENTS.md b/AGENTS.md index 1e16fb25..81d90288 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ This document provides key patterns and gotchas for developers and AI assistants | [Fieldset Options](#fieldset-options) | Supported keys for `fieldsets` / `sbadmin_fieldsets`, including descriptions, classes, dynamic regions, and collapse | | [Detail View Layout (Sidebar)](#detail-view-layout-sidebar) | Placing fieldsets in the right sidebar using `DETAIL_STRUCTURE_RIGHT_CLASS` | | [Detail View Tabs](#detail-view-tabs-sbadmin_tabs) | Organizing fieldsets and inlines into tabs with `sbadmin_tabs` | +| [Dashboard Widgets](#dashboard-widgets) | Standalone dashboards: simple widgets, parent widgets with subwidgets, grouped AJAX widgets | | [Detail View Widgets](#detail-view-widgets) | Embedding dashboard-style list/chart widgets inside detail fieldsets | | [Logo Customization](#logo-customization) | Override logo via static files | | [URL-Callable Action Methods (`@sbadmin_action`)](#url-callable-action-methods-sbadmin_action) | `@sbadmin_action` decorator for URL-callable view methods | @@ -84,6 +85,8 @@ This document provides key patterns and gotchas for developers and AI assistants - **Collapse a form fieldset?** → [Fieldset Options](#fieldset-options) - **Fields in sidebar?** → [Detail View Layout (Sidebar)](#detail-view-layout-sidebar) - **Fieldsets/inlines in tabs?** → [Detail View Tabs](#detail-view-tabs-sbadmin_tabs) +- **Building a dashboard page?** → [Dashboard Widgets](#dashboard-widgets) +- **One AJAX call for several dashboard widgets?** → [Grouped AJAX parent widget](#3-widget-with-subwidgets-and-grouped-ajax) - **List or chart inside detail page?** → [Detail View Widgets](#detail-view-widgets) - **Custom permission system (non-Django)?** → [Custom Permission System](#custom-permission-system-has_permission) - **Audit trail / change history?** → [Audit Logging](#audit-logging) @@ -4448,6 +4451,375 @@ In this example, the "Content" tab has a two-column layout (main fields on the l --- +## Dashboard Widgets + +Use `SBAdminDashboardView` in `registered_views` to build standalone dashboard pages. Dashboard widgets are regular SBAdmin views: each widget has a stable `widget_id`, permission checks, optional settings/filters, a template, media, and an AJAX `action_get_data` endpoint. + +Register dashboards from the configuration: + +```python +from django.utils.translation import gettext_lazy as _ + +from django_smartbase_admin.engine.configuration import SBAdminRoleConfiguration +from django_smartbase_admin.engine.menu_item import SBAdminMenuItem +from django_smartbase_admin.views.dashboard_view import SBAdminDashboardView + + +class AdminConfiguration(SBAdminRoleConfiguration): + default_view = SBAdminMenuItem(view_id="dashboard") + menu_items = [ + SBAdminMenuItem(label=_("Dashboard"), icon="All-application", view_id="dashboard"), + ] + registered_views = [ + SBAdminDashboardView( + widgets=[ + # Put widget instances here. + ], + title=_("Dashboard"), + ), + ] +``` + +### 1. Simple Widget + +Use `SBAdminDashboardHtmlWidget` when the server can render the whole widget as HTML. This is the smallest dashboard widget and works well for counters, static tables, status panels, or summaries. + +```python +# dashboard_widgets.py +from django.utils.translation import gettext_lazy as _ + +from django_smartbase_admin.engine.dashboard import SBAdminDashboardHtmlWidget + + +SAMPLE_REVENUE_ROWS = [ + {"currency": "EUR", "amount": "1 234,50 €", "orders": 23}, + {"currency": "CZK", "amount": "5 600 Kč", "orders": 8}, + {"currency": "HUF", "amount": "125 000 Ft", "orders": 4}, +] + + +class RevenueTableWidget(SBAdminDashboardHtmlWidget): + widget_id = "revenue_table" + name = _("Revenue") + content_template_name = "dashboard/revenue_table.html" + + def has_view_permission(self, request, obj=None) -> bool: + return True + + def get_html_context_data(self, request): + return {"rows": SAMPLE_REVENUE_ROWS} +``` + +```django +{# templates/dashboard/revenue_table.html #} +{% load i18n %} + +

{% trans "Revenue" %}

+ + + + + + + + + + {% for row in rows %} + + + + + + {% endfor %} + +
{% trans "Currency" %}{% trans "Amount" %}{% trans "Orders" %}
{{ row.currency }}{{ row.amount }}{{ row.orders }}
+``` + +```python +# configuration.py +registered_views = [ + SBAdminDashboardView( + widgets=[RevenueTableWidget()], + title=_("Dashboard"), + ), +] +``` + +### 2. Widget With Subwidgets + +Use a parent `SBAdminDashboardWidget` with `sub_widgets` when the parent owns common layout/settings and children should render inside it. In the normal mode, each child widget keeps its own AJAX behavior. + +When the parent renders settings/filters through `widget_base.html`, nested chart widgets listen to the parent filter form automatically. A change in a parent setting such as `period` triggers each child chart's own AJAX call with the same filter data. This is the right mode when children can fetch independently and you do not need to coalesce their queries. + +```python +# dashboard_widgets.py +from django.utils.translation import gettext_lazy as _ + +from django_smartbase_admin.engine.dashboard import ( + SBAdminDashboardChartWidget, + SBAdminDashboardWidget, +) +from django_smartbase_admin.engine.field import SBAdminField +from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget + + +class SalesDashboardWidget(SBAdminDashboardWidget): + widget_id = "sales_dashboard" + name = _("Sales dashboard") + template_name = "dashboard/sales_dashboard.html" + + def __init__(self, *args, **kwargs): + settings = [ + SBAdminField( + title=_("Period"), + name="period", + filter_widget=ChoiceFilterWidget( + choices=[("week", _("Week")), ("month", _("Month"))], + default_value="month", + allow_clear=False, + ), + ) + ] + super().__init__(*args, settings=settings, **kwargs) + + def has_view_permission(self, request, obj=None) -> bool: + return True + + +class OrdersChartWidget(SBAdminDashboardChartWidget): + name = _("Orders") + chart_type = "bar" + + def has_view_permission(self, request, obj=None) -> bool: + return True + + def get_data(self, request): + return { + "main": { + "labels": ["Jan", "Feb", "Mar"], + "datasets": [ + { + "label": str(_("Orders")), + "data": [12, 19, 7], + "backgroundColor": "#2368A9", + } + ], + } + } + + +class RevenueChartWidget(SBAdminDashboardChartWidget): + name = _("Revenue") + chart_type = "line" + + def has_view_permission(self, request, obj=None) -> bool: + return True + + def get_data(self, request): + return { + "main": { + "labels": ["Jan", "Feb", "Mar"], + "datasets": [ + { + "label": str(_("Revenue")), + "data": [1200, 1800, 900], + "borderColor": "#24B47E", + } + ], + } + } +``` + +```django +{# templates/dashboard/sales_dashboard.html #} +{% extends "sb_admin/dashboard/widget_base.html" %} +{% load sb_admin_tags %} + +{% block content_inner %} +
+ {% for sub_widget in sub_widgets %} + {% render_widget sub_widget request %} + {% endfor %} +
+{% endblock %} +``` + +```python +# configuration.py +registered_views = [ + SBAdminDashboardView( + widgets=[ + SalesDashboardWidget( + sub_widgets=[ + OrdersChartWidget(), + RevenueChartWidget(), + ], + ), + ], + title=_("Dashboard"), + ), +] +``` + +### 3. Widget With Subwidgets and Grouped AJAX + +Set `group_ajax_calls=True` on the parent when several subwidgets should refresh from one parent AJAX response. This is useful for dashboards with one global filter form and multiple related child widgets. The parent calls each child `get_data(request)` and returns: + +```python +{ + "sub_widget": { + "sales_dashboard_0": {...}, + "sales_dashboard_1": {...}, + } +} +``` + +Child widgets still own how they consume their data: +- `SBAdminDashboardChartWidget` registers itself with the parent group and updates the chart from its data slice. +- `SBAdminDashboardHtmlWidget` registers itself with the parent group and replaces its own HTML from `{"html": "..."}`. +- Custom widgets can call `window.SBAdminRegisterDashboardSubWidget(parentWidgetId, {widgetId, onData})` from their own template. + +```python +# dashboard_widgets.py +from django.utils.translation import gettext_lazy as _ + +from django_smartbase_admin.engine.dashboard import ( + SBAdminDashboardChartWidget, + SBAdminDashboardHtmlWidget, + SBAdminDashboardWidget, +) +from django_smartbase_admin.engine.field import SBAdminField +from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget, DateFilterWidget + + +class GroupedSalesDashboardWidget(SBAdminDashboardWidget): + widget_id = "sales_dashboard" + name = _("Sales dashboard") + group_ajax_calls = True + + def __init__(self, *args, **kwargs): + settings = [ + SBAdminField( + title=_("Date"), + name="created_at", + filter_widget=DateFilterWidget( + shortcuts=[ + {"value": [-30, 0], "label": _("Last 30 days")}, + {"value": [-90, 0], "label": _("Last 90 days")}, + ], + default_value_shortcut_index=0, + allow_clear=False, + ), + ), + SBAdminField( + title=_("Resolution"), + name="resolution", + filter_widget=ChoiceFilterWidget( + choices=[("day", _("Day")), ("month", _("Month"))], + default_value="month", + allow_clear=False, + ), + ), + ] + super().__init__(*args, settings=settings, **kwargs) + + def has_view_permission(self, request, obj=None) -> bool: + return True + + def get_data(self, request): + request.sales_dashboard_data = self.build_sales_dashboard_data(request) + return super().get_data(request) + + def build_sales_dashboard_data(self, request): + resolution = request.request_data.request_get.get("resolution", "month") + if resolution == "day": + return { + "labels": ["Mon", "Tue", "Wed"], + "orders": [4, 9, 6], + "revenue_rows": [ + {"currency": "EUR", "amount": "450,00 €", "orders": 4}, + {"currency": "CZK", "amount": "2 100 Kč", "orders": 2}, + ], + } + return { + "labels": ["Jan", "Feb", "Mar"], + "orders": [12, 19, 7], + "revenue_rows": [ + {"currency": "EUR", "amount": "1 234,50 €", "orders": 23}, + {"currency": "CZK", "amount": "5 600 Kč", "orders": 8}, + {"currency": "HUF", "amount": "125 000 Ft", "orders": 4}, + ], + } + + +class GroupedOrdersChartWidget(SBAdminDashboardChartWidget): + name = _("Orders") + chart_type = "bar" + + def has_view_permission(self, request, obj=None) -> bool: + return True + + def get_data(self, request): + dashboard_data = request.sales_dashboard_data + return { + "main": { + "labels": dashboard_data["labels"], + "datasets": [ + { + "label": str(_("Orders")), + "data": dashboard_data["orders"], + "backgroundColor": "#2368A9", + } + ], + } + } + + +class GroupedRevenueTableWidget(SBAdminDashboardHtmlWidget): + name = _("Revenue by currency") + content_template_name = "dashboard/revenue_table.html" + + def has_view_permission(self, request, obj=None) -> bool: + return True + + def get_html_context_data(self, request): + dashboard_data = getattr(request, "sales_dashboard_data", None) + rows = dashboard_data["revenue_rows"] if dashboard_data else [] + return {"rows": rows} +``` + +The example above stores shared data on `request` inside the parent `get_data()` before calling `super().get_data(request)`. This keeps child signatures unchanged (`get_data(request)` / `get_html_context_data(request)`) and lets a project do one service/repository call in the parent. Prefer a project-specific attribute name such as `request.sales_dashboard_data`; avoid broad names like `request.dashboard_data` when multiple grouped widgets can appear on the same page. + +`SBAdminDashboardHtmlWidget` also renders once during the initial page load, before the grouped AJAX request has called the parent `get_data()`. If the HTML child reads request-shared data, guard with `getattr(request, "...", None)` and return empty/sample/initial rows for the first render. The grouped AJAX response will replace the HTML afterwards. + +```python +# configuration.py +registered_views = [ + SBAdminDashboardView( + widgets=[ + GroupedSalesDashboardWidget( + sub_widgets=[ + GroupedOrdersChartWidget(), + GroupedRevenueTableWidget(), + ], + ), + ], + title=_("Dashboard"), + ), +] +``` + +**Grouped AJAX rules:** +- Put global settings/filters on the parent widget. +- Pass widget instances in `sub_widgets`; do not use string import paths unless a project has a strong reason. +- Keep the group parent generic. It should aggregate child data, not know about charts, tables, calendars, or app-specific widgets. +- Each child widget owns its rendering/update contract. Charts return Chart.js-shaped data; HTML widgets return `{"html": rendered_html}`. +- `SBAdminDashboardListWidget` can be rendered inside a grouped parent, but it keeps its own table AJAX endpoint. Use grouped AJAX for summary/chart/static HTML widgets, not for full Tabulator list data. + +**Source:** `django_smartbase_admin/engine/dashboard.py`; `django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html`; `django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html`; `django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js` + +--- + ## Detail View Widgets SBAdmin admins can register dashboard-style widgets and place them inside detail/change fieldsets. Use this when related data belongs on the object page but should stay read-only and self-contained, for example a compact related-row list or a small aggregate chart. diff --git a/pyproject.toml b/pyproject.toml index 02a626b8..65756d20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-smartbase-admin" -version = "2.0.9" +version = "2.0.10b1" description = "" authors = ["SmartBase "] readme = "README.md" diff --git a/src/django_smartbase_admin/engine/dashboard.py b/src/django_smartbase_admin/engine/dashboard.py index cbe4720d..1c9d9359 100644 --- a/src/django_smartbase_admin/engine/dashboard.py +++ b/src/django_smartbase_admin/engine/dashboard.py @@ -1,16 +1,16 @@ from copy import copy from datetime import timedelta + from django import forms from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured from django.db import models from django.db.models import QuerySet -from django.db.models.functions import TruncMonth, TruncDay, TruncWeek, TruncYear +from django.db.models.functions import TruncDay, TruncMonth, TruncWeek, TruncYear from django.http import JsonResponse from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import gettext_lazy as _ - from django_smartbase_admin.actions.admin_action_list import SBAdminListAction from django_smartbase_admin.engine.actions import sbadmin_action from django_smartbase_admin.engine.admin_base_view import SBAdminBaseListView @@ -37,6 +37,9 @@ class SBAdminDashboardWidget(SBAdminView): sub_widgets = None global_filter_data_map = None cache_enabled = False + group_ajax_calls = False + group_ajax_media = forms.Media(js=("sb_admin/dist/dashboard_group.js",)) + group_ajax_template_name = "sb_admin/dashboard/group_widget.html" SUB_WIDGET_NAME_SUFFIX = "_sub_widget" path_to_parent_instance_id = None @@ -50,9 +53,14 @@ def __init__( settings=None, sub_widgets=None, global_filter_data_map=None, + group_ajax_calls=None, ) -> None: super().__init__() + if group_ajax_calls is not None: + self.group_ajax_calls = group_ajax_calls self.template_name = self.template_name or template_name + if self.group_ajax_calls and self.template_name is None: + self.template_name = self.group_ajax_template_name self.name = self.name or name self.model = self.model or model self.annotates = self.annotates or annotates @@ -75,6 +83,12 @@ def init_widget_static(self, configuration): filter.init_field_static(self, configuration) for setting in self.get_settings(): setting.init_field_static(self, configuration) + for index, sub_widget in enumerate(self.get_sub_widgets()): + sub_widget.init_sub_widget_dynamic(str(index), self) + sub_widget.init_widget_static(configuration) + sub_widget_id = sub_widget.get_id() + if sub_widget_id: + configuration.view_map[sub_widget_id] = sub_widget def get_id(self): return self.widget_id @@ -86,10 +100,17 @@ def get_settings(self): return self.settings def get_ajax_url(self, request=None): + if self.has_parent_group_ajax_calls(): + return self.parent_view.get_ajax_url(request) return self.get_action_url( "action_get_data", object_id=self.get_parent_instance_id(request) ) + def get_filter_form_id(self): + if self.parent_view is not None: + return f"{self.parent_view.get_id()}-filter-form" + return f"{self.get_id()}-filter-form" + def get_parent_instance_id(self, request): request_data = getattr(request, "request_data", None) return getattr(request_data, "object_id", None) @@ -119,7 +140,7 @@ def action_get_data(self, request, modifier, object_id=None): return JsonResponse(data={"data": self.get_cached_data(request)}) def get_widget_context_data(self, request): - return { + context = { "widget_id": self.get_id(), "widget_name": self.name, "ajax_url": self.get_ajax_url(request), @@ -127,7 +148,11 @@ def get_widget_context_data(self, request): "settings": self.get_settings(), "sub_widgets": self.get_sub_widgets(), "request": request, + "filter_form_id": self.get_filter_form_id(), } + if self.has_parent_group_ajax_calls(): + context["parent_widget_id"] = self.parent_view.get_id() + return context def get_sub_widgets(self): return self.widget_views if self.widget_views is not None else self.sub_widgets @@ -140,12 +165,16 @@ def get_media(self): widget_media = self.media if widget_media: media += widget_media + if self.group_ajax_calls: + media += self.group_ajax_media for widget in self.get_sub_widgets(): if hasattr(widget, "get_media"): media += widget.get_media() return media def get_template_name(self): + if self.group_ajax_calls and self.template_name is None: + return self.group_ajax_template_name return self.template_name def render(self, request): @@ -167,6 +196,13 @@ def get_settings_from_request(self, request): return settings def get_data(self, request): + if self.group_ajax_calls: + return { + "sub_widget": { + sub_widget.get_id(): sub_widget.get_data(request) + for sub_widget in self.get_sub_widgets() + } + } raise NotImplementedError def get_cached_data(self, request): @@ -188,6 +224,22 @@ def get_active_sub_widget(self, request): return sub_widget return next(iter(sub_widgets), None) + def init_sub_widget_dynamic(self, sub_widget_id, parent_view): + self.widget_id = sub_widget_id + self.view_id = sub_widget_id + self.parent_view = parent_view + + def has_parent_group_ajax_calls(self): + return self.parent_view is not None and getattr( + self.parent_view, "group_ajax_calls", False + ) + + def init_view_dynamic(self, request, request_data=None, **kwargs): + result = super().init_view_dynamic(request, request_data, **kwargs) + for sub_widget in self.get_sub_widgets(): + sub_widget.init_view_dynamic(request, request_data, **kwargs) + return result + class SBAdminChartAggregateSubWidget(object): title = None @@ -253,6 +305,33 @@ def init_view_dynamic(self, request, request_data=None, **kwargs): pass +class SBAdminDashboardHtmlWidget(SBAdminDashboardWidget): + template_name = "sb_admin/dashboard/html_widget.html" + content_template_name = None + + def get_html_context_data(self, request): + return {} + + def get_html(self, request): + if self.content_template_name is None: + raise ImproperlyConfigured( + f"{self.__class__.__name__} must define content_template_name." + ) + return render_to_string( + self.content_template_name, + self.get_html_context_data(request), + request=request, + ) + + def get_data(self, request): + return {"html": self.get_html(request)} + + def get_widget_context_data(self, request): + context = super().get_widget_context_data(request) + context["html"] = self.get_html(request) + return context + + class SBAdminDashboardChartWidget(SBAdminDashboardWidget): template_name = "sb_admin/dashboard/chart_widget.html" media = forms.Media(js=("sb_admin/dist/chart.js",)) diff --git a/src/django_smartbase_admin/static/sb_admin/build/webpack.common.js b/src/django_smartbase_admin/static/sb_admin/build/webpack.common.js index 1ab8fda1..4702975e 100644 --- a/src/django_smartbase_admin/static/sb_admin/build/webpack.common.js +++ b/src/django_smartbase_admin/static/sb_admin/build/webpack.common.js @@ -7,6 +7,7 @@ const entries = { main: './src/django_smartbase_admin/static/sb_admin/src/js/main.js', table: './src/django_smartbase_admin/static/sb_admin/src/js/table.js', chart: './src/django_smartbase_admin/static/sb_admin/src/js/chart.js', + dashboard_group: './src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js', calendar: './src/django_smartbase_admin/static/sb_admin/src/js/calendar.js', main_style: './src/django_smartbase_admin/static/sb_admin/src/css/style.css', translations: './src/django_smartbase_admin/static/sb_admin/src/js/translations.js', diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/chart.js b/src/django_smartbase_admin/static/sb_admin/src/js/chart.js index 15129423..d6ddecf6 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/js/chart.js +++ b/src/django_smartbase_admin/static/sb_admin/src/js/chart.js @@ -1,5 +1,5 @@ import Chart from "chart.js/auto" -import {filterInputValueChangedUtil, filterInputValueChangeListener} from "./utils" +import {ensureFilterForm, filterInputValueChangedUtil, filterInputValueChangeListener} from "./utils" Chart.defaults.font.family = 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"' @@ -8,7 +8,16 @@ class SBAdminChart { constructor(options) { this.options = options this.initChart() - this.initFilters() + this.initThemeRefresh() + if (this.options.parentWidgetId) { + this.registerParentGroup() + } else { + this.refreshData() + document.addEventListener(window.sb_admin_const.TABLE_RELOAD_DATA_EVENT_NAME, () => { + this.refreshData() + }) + this.initFilters() + } } getGradient(gradientColorStart, gradientColorStop) { @@ -32,10 +41,24 @@ class SBAdminChart { options: this.options.chartOptions || {}, plugins: this.options.chartPlugins || [] }) - this.refreshData() - document.addEventListener(window.sb_admin_const.TABLE_RELOAD_DATA_EVENT_NAME, () => { - this.refreshData() - }) + } + + initThemeRefresh() { + const refreshChartTheme = () => { + if (this.chart) { + this.chart.update('none') + } + } + document.body.addEventListener('color-scheme-change', refreshChartTheme) + if (!window.matchMedia) { + return + } + const media = window.matchMedia('(prefers-color-scheme: dark)') + if (media.addEventListener) { + media.addEventListener('change', refreshChartTheme) + } else if (media.addListener) { + media.addListener(refreshChartTheme) + } } processDatasets(datasets) { @@ -48,8 +71,11 @@ class SBAdminChart { } refreshData() { + ensureFilterForm(this.options.formId) const filterForm = document.getElementById(this.options.formId) - const filterData = new FormData(filterForm).entries() + const filterData = ( + filterForm instanceof HTMLFormElement ? new FormData(filterForm) : new FormData() + ).entries() const filterDataNotEmpty = {} for (const [key, value] of filterData) { if (value) { @@ -64,41 +90,57 @@ class SBAdminChart { }, }).then(response => response.json()) .then(res => { - this.chart.data.labels = res.data.main.labels - this.chart.data.datasets = this.processDatasets(res.data.main.datasets) - if (this.chart.data.labels.length >= 1) { - this.chart.canvas.classList.remove('!hidden') - } else { - this.chart.canvas.classList.add('!hidden') - } - this.chart.update() - const subWidgets = res.data.sub_widget - if (subWidgets) { - Object.keys(subWidgets).forEach((widgetId) => { - const valueEl = document.getElementById(widgetId) - if (valueEl) { - valueEl.innerHTML = subWidgets[widgetId]['formatted_value'] || 0 - } - }) + this.updateData(res.data) + }) + } + + updateData(data) { + this.chart.data.labels = data.main.labels + this.chart.data.datasets = this.processDatasets(data.main.datasets) + if (this.chart.data.labels.length >= 1) { + this.chart.canvas.classList.remove('!hidden') + } else { + this.chart.canvas.classList.add('!hidden') + } + this.chart.update() + const subWidgets = data.sub_widget + if (subWidgets) { + Object.keys(subWidgets).forEach((widgetId) => { + const valueEl = document.getElementById(widgetId) + if (valueEl) { + valueEl.innerHTML = subWidgets[widgetId]['formatted_value'] || 0 } - const subWidgetsCompare = res.data.sub_widget_compare - if (subWidgetsCompare) { - Object.keys(subWidgetsCompare).forEach((widgetId) => { - const valueEl = document.getElementById(`${widgetId}_compare`) - if (valueEl) { - const subData = subWidgets[widgetId]['raw_value'] || 0 - const subDataCompare = subWidgetsCompare[widgetId]['raw_value'] || 0 - const percentage = (((subData / subDataCompare) - 1) * 100).toFixed(2) - if (percentage !== 'NaN' && percentage !== 'Infinity') { - valueEl.innerHTML = percentage + "%" - } else { - valueEl.innerHTML = '' - } - } - }) + }) + } + const subWidgetsCompare = data.sub_widget_compare + if (subWidgetsCompare) { + Object.keys(subWidgetsCompare).forEach((widgetId) => { + const valueEl = document.getElementById(`${widgetId}_compare`) + if (valueEl) { + const subData = (subWidgets && subWidgets[widgetId] && subWidgets[widgetId]['raw_value']) || 0 + const subDataCompare = subWidgetsCompare[widgetId]['raw_value'] || 0 + const percentage = (((subData / subDataCompare) - 1) * 100).toFixed(2) + if (percentage !== 'NaN' && percentage !== 'Infinity') { + valueEl.innerHTML = percentage + "%" + } else { + valueEl.innerHTML = '' + } } - this.chart.canvas.dispatchEvent(new CustomEvent('chartDataLoaded')) }) + } + if (this.options.onData) { + this.options.onData(data) + } + this.chart.canvas.dispatchEvent(new CustomEvent('chartDataLoaded')) + } + + registerParentGroup() { + window.SBAdminRegisterDashboardSubWidget(this.options.parentWidgetId, { + widgetId: this.options.widgetId, + onData: (data) => { + this.updateData(data) + }, + }) } initFilters() { @@ -110,3 +152,4 @@ class SBAdminChart { } window.SBAdminChartClass = SBAdminChart +document.dispatchEvent(new Event('SBAdminChartClassLoaded')) diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js b/src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js new file mode 100644 index 00000000..dee4dc47 --- /dev/null +++ b/src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js @@ -0,0 +1,109 @@ +import {filterInputValueChangeListener, filterInputValueChangedUtil} from "./utils" + +class SBAdminDashboardGroup { + constructor(element) { + this.element = element + this.groupId = element.dataset.dashboardGroupId + this.formId = element.dataset.filterFormId + this.ajaxUrl = element.dataset.ajaxUrl + this.subWidgets = {} + this.lastData = null + this.initialized = false + } + + formValues() { + const values = {} + const form = document.getElementById(this.formId) + const entries = form ? new FormData(form).entries() : new FormData() + for (const [key, value] of entries) { + if (value) { + values[key] = value + } + } + if (!form) { + document.querySelectorAll(`[form="${this.formId}"]`).forEach((input) => { + if (input.name && input.value) { + values[input.name] = input.value + } + }) + } + return values + } + + updateSubWidget(definition, responseData) { + const widgetData = responseData.sub_widget[definition.widgetId] + if (!widgetData || !definition.onData) { + return + } + definition.onData(widgetData) + } + + refresh() { + fetch(`${this.ajaxUrl}?${new URLSearchParams(this.formValues())}`, { + method: 'GET', + headers: {"X-CSRFToken": window.csrf_token}, + }).then(response => response.json()).then(response => { + this.lastData = response.data + Object.values(this.subWidgets).forEach((definition) => { + this.updateSubWidget(definition, this.lastData) + }) + }) + } + + registerSubWidget(definition) { + this.subWidgets[definition.widgetId] = definition + if (this.lastData) { + this.updateSubWidget(definition, this.lastData) + } + } + + init() { + if (this.initialized) { + return + } + this.initialized = true + this.refresh() + + document.addEventListener(window.sb_admin_const.TABLE_RELOAD_DATA_EVENT_NAME, () => { + this.refresh() + }) + filterInputValueChangeListener(`[form="${this.formId}"]`, (event) => { + this.refresh() + filterInputValueChangedUtil(event.target) + }) + } +} + +function initDashboardGroups() { + window.SBAdminDashboardGroups = window.SBAdminDashboardGroups || {} + document.querySelectorAll('[data-dashboard-group-id]').forEach((element) => { + const groupId = element.dataset.dashboardGroupId + const group = window.SBAdminDashboardGroups[groupId] || new SBAdminDashboardGroup(element) + window.SBAdminDashboardGroups[groupId] = group + const pendingDefinitions = window.SBAdminDashboardGroupPendingSubWidgets[groupId] || [] + pendingDefinitions.forEach((definition) => { + group.registerSubWidget(definition) + }) + window.SBAdminDashboardGroupPendingSubWidgets[groupId] = [] + group.init() + }) +} + +window.SBAdminDashboardGroups = window.SBAdminDashboardGroups || {} +window.SBAdminDashboardGroupPendingSubWidgets = window.SBAdminDashboardGroupPendingSubWidgets || {} +window.SBAdminRegisterDashboardSubWidget = function(groupId, definition) { + const group = window.SBAdminDashboardGroups[groupId] + if (group) { + group.registerSubWidget(definition) + return + } + window.SBAdminDashboardGroupPendingSubWidgets[groupId] = window.SBAdminDashboardGroupPendingSubWidgets[groupId] || [] + window.SBAdminDashboardGroupPendingSubWidgets[groupId].push(definition) +} + +if (window.SBAdminMainLoaded) { + initDashboardGroups() +} else { + document.addEventListener('SBAdminMainLoaded', initDashboardGroups, {once: true}) +} +document.dispatchEvent(new Event('SBAdminDashboardGroupLoaded')) diff --git a/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html b/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html index 302b10c7..5e7b0b47 100644 --- a/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html +++ b/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html @@ -16,11 +16,19 @@ diff --git a/src/django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html b/src/django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html new file mode 100644 index 00000000..e7e07159 --- /dev/null +++ b/src/django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html @@ -0,0 +1,23 @@ +{% extends "sb_admin/dashboard/widget_base.html" %} +{% load sb_admin_tags %} + +{% block filters %} +{% endblock %} + +{% block content_inner %} +
+
+ {% include "sb_admin/components/filters.html" with filters=settings all_filters_visible=True default_button=True view_id=widget_id %} + {% include "sb_admin/components/filters.html" with filters=filters all_filters_visible=True default_button=True view_id=widget_id %} +
+
+ {% for sub_widget in sub_widgets %} + {% render_widget sub_widget request %} + {% endfor %} +
+
+{% endblock %} diff --git a/src/django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html b/src/django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html new file mode 100644 index 00000000..8225c712 --- /dev/null +++ b/src/django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html @@ -0,0 +1,36 @@ +{% extends "sb_admin/dashboard/widget_base.html" %} + +{% block filters %} +{% endblock %} + +{% block content_inner %} +
+
+ {{ html|safe }} +
+
+ {% if parent_widget_id %} + + {% endif %} +{% endblock %} diff --git a/src/django_smartbase_admin/tests/test_dashboard.py b/src/django_smartbase_admin/tests/test_dashboard.py index 3f6a9377..fd11b218 100644 --- a/src/django_smartbase_admin/tests/test_dashboard.py +++ b/src/django_smartbase_admin/tests/test_dashboard.py @@ -8,8 +8,8 @@ from django.core.exceptions import ImproperlyConfigured from django.db import models from django.db.models import F -from django.test import RequestFactory, SimpleTestCase from django.template.loader import render_to_string +from django.test import RequestFactory, SimpleTestCase from django_smartbase_admin.actions.admin_action_list import SBAdminListAction from django_smartbase_admin.admin.admin_base import SBAdmin from django_smartbase_admin.engine.configuration import SBAdminRoleConfiguration @@ -17,8 +17,9 @@ from django_smartbase_admin.engine.dashboard import ( SbAdminCalendarWidget, SBAdminDashboardChartWidget, - SBAdminDashboardWidget, + SBAdminDashboardHtmlWidget, SBAdminDashboardListWidget, + SBAdminDashboardWidget, ) from django_smartbase_admin.engine.dynamic_forms import SBDynamicRegion from django_smartbase_admin.engine.field import SBAdminField @@ -87,6 +88,43 @@ def get_data(self, request): return {"object_id": request.request_data.object_id} +class _DashboardGroupSubWidget(SBAdminDashboardWidget): + template_name = "sb_admin/blank_base.html" + name = "Sub widget" + + def has_view_or_change_permission(self, request, obj=None): + return True + + def get_data(self, request): + return {"value": 1} + + +class _DashboardHtmlSubWidget(SBAdminDashboardHtmlWidget): + name = "HTML sub widget" + + def has_view_or_change_permission(self, request, obj=None): + return True + + def get_html(self, request): + return "

Rendered HTML

" + + +class _DashboardGroupWidget(SBAdminDashboardWidget): + widget_id = "dashboard_group_widget" + name = "Group widget" + group_ajax_calls = True + sub_widgets = [ + _DashboardGroupSubWidget(), + _DashboardGroupSubWidget(), + ] + + def get_action_url(self, action, modifier="template", object_id=None): + return f"/{self.get_id()}/{action}/{modifier}/" + + def has_view_or_change_permission(self, request, obj=None): + return True + + class _WidgetAdmin(SBAdmin): widgets = [_RegisteredAdminWidget] sbadmin_fieldsets = [(None, {"fields": []})] @@ -114,6 +152,28 @@ class Meta: managed = False +class _DashboardGroupChartSubWidget(SBAdminDashboardChartWidget): + widget_id = "chart_sub_widget" + model = FieldsetWidgetTestModel + chart_type = "line" + x_axis_annotate = F("username") + + def has_view_or_change_permission(self, request, obj=None): + return True + + +class _DashboardChartGroupWidget(_DashboardGroupWidget): + sub_widgets = [_DashboardGroupChartSubWidget()] + + +class _DashboardListGroupWidget(_DashboardGroupWidget): + sub_widgets = [_StandaloneDashboardWidget()] + + +class _DashboardHtmlGroupWidget(_DashboardGroupWidget): + sub_widgets = [_DashboardHtmlSubWidget()] + + class _FieldsetWidgetAdmin(_WidgetAdmin): sbadmin_fieldsets = [ ( @@ -579,6 +639,140 @@ def test_dashboard_widget_cache_key_includes_request_object_id(self): widget.get_cached_data(second_request), {"object_id": "parent-2"} ) + def test_dashboard_group_widget_initializes_existing_sub_widgets(self): + widget = _DashboardGroupWidget() + request = self.factory.get("/dashboard/") + request.request_data = SimpleNamespace( + configuration=SBAdminRoleConfiguration(), + request_get={}, + request_method="GET", + object_id=None, + ) + + widget.init_view_dynamic(request, request_data=request.request_data) + sub_widgets = widget.get_sub_widgets() + + self.assertEqual(sub_widgets[0].get_id(), "dashboard_group_widget_0") + self.assertEqual(sub_widgets[1].get_id(), "dashboard_group_widget_1") + self.assertIs(sub_widgets[0].parent_view, widget) + self.assertEqual( + widget.get_data(request), + { + "sub_widget": { + "dashboard_group_widget_0": {"value": 1}, + "dashboard_group_widget_1": {"value": 1}, + } + }, + ) + + def test_dashboard_group_widget_template_owns_single_parent_ajax_call(self): + widget = _DashboardGroupWidget() + request = self.factory.get("/dashboard/") + request.request_data = SimpleNamespace( + configuration=SBAdminRoleConfiguration(), + request_get={}, + request_method="GET", + object_id=None, + ) + widget.init_view_dynamic(request, request_data=request.request_data) + + html = render_to_string( + widget.template_name, + widget.get_widget_context_data(request), + request=request, + ) + + self.assertIn('data-dashboard-group-id="dashboard_group_widget"', html) + self.assertIn('data-filter-form-id="dashboard_group_widget-filter-form"', html) + self.assertIn( + 'data-ajax-url="/dashboard_group_widget/action_get_data/template/"', html + ) + self.assertNotIn("registerChart", html) + self.assertNotIn("setTimeout", html) + + def test_dashboard_group_widget_renders_default_chart_subwidget_without_standalone_ajax( + self, + ): + widget = _DashboardChartGroupWidget() + request = self.factory.get("/dashboard/") + request.request_data = SimpleNamespace( + configuration=SBAdminRoleConfiguration(), + request_get={}, + request_method="GET", + object_id=None, + ) + widget.init_view_dynamic(request, request_data=request.request_data) + + html = render_to_string( + widget.template_name, + widget.get_widget_context_data(request), + request=request, + ) + + self.assertIn('"parentWidgetId": "dashboard_group_widget"', html) + self.assertIn('"widgetId": "dashboard_group_widget_0"', html) + self.assertNotIn( + '"ajaxUrl": "/dashboard_group_widget_0/action_get_data/template/"', html + ) + self.assertIn("SBAdminChartClassLoaded", html) + + def test_dashboard_group_widget_keeps_list_subwidget_table_ajax(self): + widget = _DashboardListGroupWidget() + request = self.factory.get("/dashboard/") + request.LANGUAGE_CODE = "en" + request.request_data = SimpleNamespace( + configuration=SBAdminRoleConfiguration(), + request_get={}, + request_method="GET", + object_id="parent-object", + user=SimpleNamespace(first_name="", last_name="", username="tester"), + ) + request.user = SimpleNamespace( + is_anonymous=True, has_perm=lambda _permission: True + ) + widget.init_view_dynamic(request, request_data=request.request_data) + + html = render_to_string( + widget.template_name, + widget.get_widget_context_data(request), + request=request, + ) + + self.assertIn("dashboard_group_widget_0-table", html) + self.assertIn( + "/dashboard_group_widget_0/action_list_json/template/parent-object/", + html, + ) + self.assertNotIn( + "/dashboard_group_widget/action_list_json/template/parent-object/", + html, + ) + + def test_dashboard_group_widget_updates_html_subwidget_from_parent_data(self): + widget = _DashboardHtmlGroupWidget() + request = self.factory.get("/dashboard/") + request.request_data = SimpleNamespace( + configuration=SBAdminRoleConfiguration(), + request_get={}, + request_method="GET", + object_id=None, + ) + widget.init_view_dynamic(request, request_data=request.request_data) + + html = render_to_string( + widget.template_name, + widget.get_widget_context_data(request), + request=request, + ) + data = widget.get_data(request) + + self.assertIn("SBAdminRegisterDashboardSubWidget", html) + self.assertIn("SBAdminDashboardGroupLoaded", html) + self.assertIn('widgetId: "dashboard_group_widget_0"', html) + self.assertIn( + "Rendered HTML", data["sub_widget"]["dashboard_group_widget_0"]["html"] + ) + def test_dashboard_list_widget_uses_batch_parent_filter_hook(self): widget = _CustomParentScopedListWidget() request = self.factory.get("/admin/auth/user/1/change/") diff --git a/src/django_smartbase_admin/views/dashboard_view.py b/src/django_smartbase_admin/views/dashboard_view.py index ea6059e3..e88c821d 100644 --- a/src/django_smartbase_admin/views/dashboard_view.py +++ b/src/django_smartbase_admin/views/dashboard_view.py @@ -35,7 +35,7 @@ def get_dashboard_media(self, request): @sbadmin_action def dashboard(self, request, modifier, object_id=None): context = self.get_global_context(request) - context["direct_sub_views"] = self.widget_views + context["direct_sub_views"] = self.get_widget_views(request, object_id) context["dashboard_media"] = self.get_dashboard_media(request) context["title"] = self.get_title() return TemplateResponse( From 5d7f6f23011b56fbdb2fbc8fb5df85b45a5c4eb2 Mon Sep 17 00:00:00 2001 From: Viliam Mihalik Date: Fri, 19 Jun 2026 10:20:26 +0200 Subject: [PATCH 2/7] triv(dashboard): simplify subwidget initialization logic by removing redundant ID assignments --- AGENTS.md | 7 ++- .../engine/dashboard.py | 51 +++++++++---------- .../tests/test_dashboard.py | 48 ++++++++++++++++- 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81d90288..3fd2f6a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4663,7 +4663,7 @@ registered_views = [ ### 3. Widget With Subwidgets and Grouped AJAX -Set `group_ajax_calls=True` on the parent when several subwidgets should refresh from one parent AJAX response. This is useful for dashboards with one global filter form and multiple related child widgets. The parent calls each child `get_data(request)` and returns: +Use `SBAdminDashboardGroupWidget` as the parent when several subwidgets should refresh from one parent AJAX response. This is useful for dashboards with one global filter form and multiple related child widgets. The parent calls each child `get_data(request)` and returns: ```python { @@ -4685,17 +4685,16 @@ from django.utils.translation import gettext_lazy as _ from django_smartbase_admin.engine.dashboard import ( SBAdminDashboardChartWidget, + SBAdminDashboardGroupWidget, SBAdminDashboardHtmlWidget, - SBAdminDashboardWidget, ) from django_smartbase_admin.engine.field import SBAdminField from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget, DateFilterWidget -class GroupedSalesDashboardWidget(SBAdminDashboardWidget): +class GroupedSalesDashboardWidget(SBAdminDashboardGroupWidget): widget_id = "sales_dashboard" name = _("Sales dashboard") - group_ajax_calls = True def __init__(self, *args, **kwargs): settings = [ diff --git a/src/django_smartbase_admin/engine/dashboard.py b/src/django_smartbase_admin/engine/dashboard.py index 1c9d9359..332c79ac 100644 --- a/src/django_smartbase_admin/engine/dashboard.py +++ b/src/django_smartbase_admin/engine/dashboard.py @@ -37,9 +37,6 @@ class SBAdminDashboardWidget(SBAdminView): sub_widgets = None global_filter_data_map = None cache_enabled = False - group_ajax_calls = False - group_ajax_media = forms.Media(js=("sb_admin/dist/dashboard_group.js",)) - group_ajax_template_name = "sb_admin/dashboard/group_widget.html" SUB_WIDGET_NAME_SUFFIX = "_sub_widget" path_to_parent_instance_id = None @@ -53,14 +50,9 @@ def __init__( settings=None, sub_widgets=None, global_filter_data_map=None, - group_ajax_calls=None, ) -> None: super().__init__() - if group_ajax_calls is not None: - self.group_ajax_calls = group_ajax_calls self.template_name = self.template_name or template_name - if self.group_ajax_calls and self.template_name is None: - self.template_name = self.group_ajax_template_name self.name = self.name or name self.model = self.model or model self.annotates = self.annotates or annotates @@ -100,8 +92,6 @@ def get_settings(self): return self.settings def get_ajax_url(self, request=None): - if self.has_parent_group_ajax_calls(): - return self.parent_view.get_ajax_url(request) return self.get_action_url( "action_get_data", object_id=self.get_parent_instance_id(request) ) @@ -150,8 +140,10 @@ def get_widget_context_data(self, request): "request": request, "filter_form_id": self.get_filter_form_id(), } - if self.has_parent_group_ajax_calls(): - context["parent_widget_id"] = self.parent_view.get_id() + parent_group_widget = self.get_parent_group_widget() + if parent_group_widget: + context["parent_widget_id"] = parent_group_widget.get_id() + context["parent_ajax_url"] = parent_group_widget.get_ajax_url(request) return context def get_sub_widgets(self): @@ -165,16 +157,12 @@ def get_media(self): widget_media = self.media if widget_media: media += widget_media - if self.group_ajax_calls: - media += self.group_ajax_media for widget in self.get_sub_widgets(): if hasattr(widget, "get_media"): media += widget.get_media() return media def get_template_name(self): - if self.group_ajax_calls and self.template_name is None: - return self.group_ajax_template_name return self.template_name def render(self, request): @@ -196,13 +184,6 @@ def get_settings_from_request(self, request): return settings def get_data(self, request): - if self.group_ajax_calls: - return { - "sub_widget": { - sub_widget.get_id(): sub_widget.get_data(request) - for sub_widget in self.get_sub_widgets() - } - } raise NotImplementedError def get_cached_data(self, request): @@ -229,10 +210,13 @@ def init_sub_widget_dynamic(self, sub_widget_id, parent_view): self.view_id = sub_widget_id self.parent_view = parent_view - def has_parent_group_ajax_calls(self): - return self.parent_view is not None and getattr( - self.parent_view, "group_ajax_calls", False - ) + def get_parent_group_widget(self): + parent_view = self.parent_view + if parent_view is not None and isinstance( + parent_view, SBAdminDashboardGroupWidget + ): + return parent_view + return None def init_view_dynamic(self, request, request_data=None, **kwargs): result = super().init_view_dynamic(request, request_data, **kwargs) @@ -241,6 +225,19 @@ def init_view_dynamic(self, request, request_data=None, **kwargs): return result +class SBAdminDashboardGroupWidget(SBAdminDashboardWidget): + template_name = "sb_admin/dashboard/group_widget.html" + media = forms.Media(js=("sb_admin/dist/dashboard_group.js",)) + + def get_data(self, request): + return { + "sub_widget": { + sub_widget.get_id(): sub_widget.get_data(request) + for sub_widget in self.get_sub_widgets() + } + } + + class SBAdminChartAggregateSubWidget(object): title = None aggregate = None diff --git a/src/django_smartbase_admin/tests/test_dashboard.py b/src/django_smartbase_admin/tests/test_dashboard.py index fd11b218..ae9386d0 100644 --- a/src/django_smartbase_admin/tests/test_dashboard.py +++ b/src/django_smartbase_admin/tests/test_dashboard.py @@ -17,6 +17,7 @@ from django_smartbase_admin.engine.dashboard import ( SbAdminCalendarWidget, SBAdminDashboardChartWidget, + SBAdminDashboardGroupWidget, SBAdminDashboardHtmlWidget, SBAdminDashboardListWidget, SBAdminDashboardWidget, @@ -109,10 +110,9 @@ def get_html(self, request): return "

Rendered HTML

" -class _DashboardGroupWidget(SBAdminDashboardWidget): +class _DashboardGroupWidget(SBAdminDashboardGroupWidget): widget_id = "dashboard_group_widget" name = "Group widget" - group_ajax_calls = True sub_widgets = [ _DashboardGroupSubWidget(), _DashboardGroupSubWidget(), @@ -166,6 +166,17 @@ class _DashboardChartGroupWidget(_DashboardGroupWidget): sub_widgets = [_DashboardGroupChartSubWidget()] +class _DashboardParentWidget(SBAdminDashboardWidget): + widget_id = "dashboard_parent_widget" + sub_widgets = [_DashboardGroupChartSubWidget()] + + def get_action_url(self, action, modifier="template", object_id=None): + return f"/{self.get_id()}/{action}/{modifier}/" + + def has_view_or_change_permission(self, request, obj=None): + return True + + class _DashboardListGroupWidget(_DashboardGroupWidget): sub_widgets = [_StandaloneDashboardWidget()] @@ -245,6 +256,11 @@ class TestSBAdminDashboardListWidget(SimpleTestCase): def setUp(self): self.factory = RequestFactory() + def init_dashboard_widget_static(self, widget): + configuration = SimpleNamespace(view_map={}) + widget.init_widget_static(configuration) + return configuration + def test_init_view_dynamic_preserves_sbadmin_field_metadata(self): widget = _DashboardWidget() request = self.factory.get("/dashboard/") @@ -641,6 +657,7 @@ def test_dashboard_widget_cache_key_includes_request_object_id(self): def test_dashboard_group_widget_initializes_existing_sub_widgets(self): widget = _DashboardGroupWidget() + self.init_dashboard_widget_static(widget) request = self.factory.get("/dashboard/") request.request_data = SimpleNamespace( configuration=SBAdminRoleConfiguration(), @@ -667,6 +684,7 @@ def test_dashboard_group_widget_initializes_existing_sub_widgets(self): def test_dashboard_group_widget_template_owns_single_parent_ajax_call(self): widget = _DashboardGroupWidget() + self.init_dashboard_widget_static(widget) request = self.factory.get("/dashboard/") request.request_data = SimpleNamespace( configuration=SBAdminRoleConfiguration(), @@ -694,6 +712,7 @@ def test_dashboard_group_widget_renders_default_chart_subwidget_without_standalo self, ): widget = _DashboardChartGroupWidget() + self.init_dashboard_widget_static(widget) request = self.factory.get("/dashboard/") request.request_data = SimpleNamespace( configuration=SBAdminRoleConfiguration(), @@ -716,8 +735,32 @@ def test_dashboard_group_widget_renders_default_chart_subwidget_without_standalo ) self.assertIn("SBAdminChartClassLoaded", html) + def test_dashboard_parent_widget_keeps_chart_subwidget_own_ajax(self): + widget = _DashboardParentWidget() + self.init_dashboard_widget_static(widget) + request = self.factory.get("/dashboard/") + request.request_data = SimpleNamespace( + configuration=SBAdminRoleConfiguration(), + request_get={}, + request_method="GET", + object_id=None, + ) + widget.init_view_dynamic(request, request_data=request.request_data) + + sub_widget = widget.get_sub_widgets()[0] + html = render_to_string( + sub_widget.template_name, + sub_widget.get_widget_context_data(request), + request=request, + ) + + self.assertNotIn('"parentWidgetId": "dashboard_parent_widget"', html) + self.assertIn('"formId": "dashboard_parent_widget-filter-form"', html) + self.assertIn("dashboard_parent_widget_0/action_get_data/template/", html) + def test_dashboard_group_widget_keeps_list_subwidget_table_ajax(self): widget = _DashboardListGroupWidget() + self.init_dashboard_widget_static(widget) request = self.factory.get("/dashboard/") request.LANGUAGE_CODE = "en" request.request_data = SimpleNamespace( @@ -750,6 +793,7 @@ def test_dashboard_group_widget_keeps_list_subwidget_table_ajax(self): def test_dashboard_group_widget_updates_html_subwidget_from_parent_data(self): widget = _DashboardHtmlGroupWidget() + self.init_dashboard_widget_static(widget) request = self.factory.get("/dashboard/") request.request_data = SimpleNamespace( configuration=SBAdminRoleConfiguration(), From 6fa98edd47a72fc2c8bb17f7453de2df6a078167 Mon Sep 17 00:00:00 2001 From: Viliam Mihalik Date: Fri, 19 Jun 2026 11:59:19 +0200 Subject: [PATCH 3/7] doc(dashboard): update `AGENTS.md` to clarify dashboard widget patterns Improved documentation for dashboard widgets, detailing simple, lightweight subwidgets, real subwidgets with separate AJAX, and grouped AJAX options. Updated examples and descriptions to align with recent dashboard changes and improved organization for clarity. --- AGENTS.md | 578 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 367 insertions(+), 211 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3fd2f6a7..a472d00b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ This document provides key patterns and gotchas for developers and AI assistants | [Fieldset Options](#fieldset-options) | Supported keys for `fieldsets` / `sbadmin_fieldsets`, including descriptions, classes, dynamic regions, and collapse | | [Detail View Layout (Sidebar)](#detail-view-layout-sidebar) | Placing fieldsets in the right sidebar using `DETAIL_STRUCTURE_RIGHT_CLASS` | | [Detail View Tabs](#detail-view-tabs-sbadmin_tabs) | Organizing fieldsets and inlines into tabs with `sbadmin_tabs` | -| [Dashboard Widgets](#dashboard-widgets) | Standalone dashboards: simple widgets, parent widgets with subwidgets, grouped AJAX widgets | +| [Dashboard Widgets](#dashboard-widgets) | Standalone dashboards: simple widgets, lightweight subwidgets, real subwidgets with separate or grouped AJAX | | [Detail View Widgets](#detail-view-widgets) | Embedding dashboard-style list/chart widgets inside detail fieldsets | | [Logo Customization](#logo-customization) | Override logo via static files | | [URL-Callable Action Methods (`@sbadmin_action`)](#url-callable-action-methods-sbadmin_action) | `@sbadmin_action` decorator for URL-callable view methods | @@ -86,7 +86,7 @@ This document provides key patterns and gotchas for developers and AI assistants - **Fields in sidebar?** → [Detail View Layout (Sidebar)](#detail-view-layout-sidebar) - **Fieldsets/inlines in tabs?** → [Detail View Tabs](#detail-view-tabs-sbadmin_tabs) - **Building a dashboard page?** → [Dashboard Widgets](#dashboard-widgets) -- **One AJAX call for several dashboard widgets?** → [Grouped AJAX parent widget](#3-widget-with-subwidgets-and-grouped-ajax) +- **One AJAX call for several dashboard widgets?** → [Real Subwidgets With One AJAX](#4-real-subwidgets-with-one-ajax) - **List or chart inside detail page?** → [Detail View Widgets](#detail-view-widgets) - **Custom permission system (non-Django)?** → [Custom Permission System](#custom-permission-system-has_permission) - **Audit trail / change history?** → [Audit Logging](#audit-logging) @@ -4455,15 +4455,27 @@ In this example, the "Content" tab has a two-column layout (main fields on the l Use `SBAdminDashboardView` in `registered_views` to build standalone dashboard pages. Dashboard widgets are regular SBAdmin views: each widget has a stable `widget_id`, permission checks, optional settings/filters, a template, media, and an AJAX `action_get_data` endpoint. +Dashboard widgets are usually built in one of four ways: + +| Pattern | Use when | +|---------|----------| +| Simple widget | The widget is independent and owns its own HTML/chart/list data. | +| Lightweight chart subwidgets | One chart has small metric tabs/aggregates over the same queryset. | +| Real subwidgets with separate AJAX | A parent owns common layout/settings, but each child should load independently. | +| Real subwidgets with one AJAX | A parent/group owns common settings and one AJAX response refreshes all children together. | + Register dashboards from the configuration: ```python +# blog/sbadmin_config.py from django.utils.translation import gettext_lazy as _ from django_smartbase_admin.engine.configuration import SBAdminRoleConfiguration from django_smartbase_admin.engine.menu_item import SBAdminMenuItem from django_smartbase_admin.views.dashboard_view import SBAdminDashboardView +from blog.dashboard_widgets import ArticleSummaryWidget + class AdminConfiguration(SBAdminRoleConfiguration): default_view = SBAdminMenuItem(view_id="dashboard") @@ -4473,86 +4485,138 @@ class AdminConfiguration(SBAdminRoleConfiguration): registered_views = [ SBAdminDashboardView( widgets=[ - # Put widget instances here. + ArticleSummaryWidget(), ], title=_("Dashboard"), ), ] ``` +All examples below assume the demo `Article` model from [Demo Schema Reference](#demo-schema-reference). + ### 1. Simple Widget -Use `SBAdminDashboardHtmlWidget` when the server can render the whole widget as HTML. This is the smallest dashboard widget and works well for counters, static tables, status panels, or summaries. +Use a simple top-level widget when it does not need a parent. This is the default choice for independent counters, cards, charts, lists, and HTML blocks. ```python -# dashboard_widgets.py +# blog/dashboard_widgets.py +from django.db.models import Count +from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from django_smartbase_admin.engine.dashboard import SBAdminDashboardHtmlWidget - -SAMPLE_REVENUE_ROWS = [ - {"currency": "EUR", "amount": "1 234,50 €", "orders": 23}, - {"currency": "CZK", "amount": "5 600 Kč", "orders": 8}, - {"currency": "HUF", "amount": "125 000 Ft", "orders": 4}, -] +from blog.models import Article -class RevenueTableWidget(SBAdminDashboardHtmlWidget): - widget_id = "revenue_table" - name = _("Revenue") - content_template_name = "dashboard/revenue_table.html" +class ArticleSummaryWidget(SBAdminDashboardHtmlWidget): + widget_id = "article_summary" + name = _("Article summary") def has_view_permission(self, request, obj=None) -> bool: return True - def get_html_context_data(self, request): - return {"rows": SAMPLE_REVENUE_ROWS} + def get_html(self, request): + counts = dict( + Article.objects.values_list("status").annotate(total=Count("id")) + ) + return format_html( + """ +
+

{title}

+
+
{draft_label}
{draft}
+
{published_label}
{published}
+
{archived_label}
{archived}
+
+
+ """, + title=_("Article summary"), + draft_label=_("Draft"), + published_label=_("Published"), + archived_label=_("Archived"), + draft=counts.get("draft", 0), + published=counts.get("published", 0), + archived=counts.get("archived", 0), + ) ``` -```django -{# templates/dashboard/revenue_table.html #} -{% load i18n %} - -

{% trans "Revenue" %}

- - - - - - - - - - {% for row in rows %} - - - - - - {% endfor %} - -
{% trans "Currency" %}{% trans "Amount" %}{% trans "Orders" %}
{{ row.currency }}{{ row.amount }}{{ row.orders }}
+Register it as a top-level widget: + +```python +SBAdminDashboardView( + widgets=[ArticleSummaryWidget()], + title=_("Dashboard"), +) ``` +### 2. Lightweight Chart Subwidgets + +Use `SBAdminChartAggregateSubWidget` when the children are not real dashboard widgets. These are lightweight metric selectors rendered inside one chart widget. They do not get their own AJAX URLs; the parent chart owns the query and response. + ```python -# configuration.py -registered_views = [ - SBAdminDashboardView( - widgets=[RevenueTableWidget()], - title=_("Dashboard"), - ), -] +# blog/dashboard_widgets.py +from django.db.models import Count, Q +from django.db.models.functions import TruncMonth +from django.utils.translation import gettext_lazy as _ + +from django_smartbase_admin.engine.dashboard import ( + SBAdminChartAggregateSubWidget, + SBAdminDashboardChartWidget, +) + +from blog.models import Article + + +class ArticleStatusChartWidget(SBAdminDashboardChartWidget): + widget_id = "article_status_chart" + name = _("Articles") + model = Article + chart_type = "line" + x_axis_annotate = TruncMonth("created_at") + y_axis_annotate = Count("id") + order_by = "x_axis" + sub_widgets = [ + SBAdminChartAggregateSubWidget( + title=_("All"), + aggregate=Count("id"), + ), + SBAdminChartAggregateSubWidget( + title=_("Published"), + aggregate=Count("id", filter=Q(status="published")), + ), + SBAdminChartAggregateSubWidget( + title=_("Draft"), + aggregate=Count("id", filter=Q(status="draft")), + ), + ] + + def has_view_permission(self, request, obj=None) -> bool: + return True + + def process_label(self, request, label, data, labels, dataset_data): + return label.strftime("%Y-%m") if label else _("No date") ``` -### 2. Widget With Subwidgets +Register it as a top-level widget: -Use a parent `SBAdminDashboardWidget` with `sub_widgets` when the parent owns common layout/settings and children should render inside it. In the normal mode, each child widget keeps its own AJAX behavior. +```python +SBAdminDashboardView( + widgets=[ArticleStatusChartWidget()], + title=_("Dashboard"), +) +``` + +### 3. Real Subwidgets With Separate AJAX -When the parent renders settings/filters through `widget_base.html`, nested chart widgets listen to the parent filter form automatically. A change in a parent setting such as `period` triggers each child chart's own AJAX call with the same filter data. This is the right mode when children can fetch independently and you do not need to coalesce their queries. +Use a normal `SBAdminDashboardWidget` parent with real dashboard widgets in `sub_widgets` when the parent owns common layout/settings, but every child should keep its own AJAX endpoint. This is useful when children may be slow or independent. + +The parent template renders each child. Chart children use the parent filter form id, so common parent settings are sent with every child AJAX request. ```python -# dashboard_widgets.py +# blog/dashboard_widgets.py +from django.db.models import Count, F +from django.db.models.functions import TruncMonth from django.utils.translation import gettext_lazy as _ from django_smartbase_admin.engine.dashboard import ( @@ -4562,76 +4626,79 @@ from django_smartbase_admin.engine.dashboard import ( from django_smartbase_admin.engine.field import SBAdminField from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget +from blog.models import Article -class SalesDashboardWidget(SBAdminDashboardWidget): - widget_id = "sales_dashboard" - name = _("Sales dashboard") - template_name = "dashboard/sales_dashboard.html" - - def __init__(self, *args, **kwargs): - settings = [ - SBAdminField( - title=_("Period"), - name="period", - filter_widget=ChoiceFilterWidget( - choices=[("week", _("Week")), ("month", _("Month"))], - default_value="month", - allow_clear=False, - ), - ) - ] - super().__init__(*args, settings=settings, **kwargs) + +class ArticleOverviewWidget(SBAdminDashboardWidget): + widget_id = "article_overview" + name = _("Article overview") + template_name = "dashboard/article_overview.html" + settings = [ + SBAdminField( + title=_("Status"), + name="status", + filter_widget=ChoiceFilterWidget( + choices=[ + ("", _("All")), + ("draft", _("Draft")), + ("published", _("Published")), + ("archived", _("Archived")), + ], + default_value="", + ), + ) + ] def has_view_permission(self, request, obj=None) -> bool: return True + def get_data(self, request): + return {} -class OrdersChartWidget(SBAdminDashboardChartWidget): - name = _("Orders") + +class ArticleMonthlyChartWidget(SBAdminDashboardChartWidget): + name = _("Articles by month") + model = Article chart_type = "bar" + x_axis_annotate = TruncMonth("created_at") + y_axis_annotate = Count("id") + order_by = "x_axis" def has_view_permission(self, request, obj=None) -> bool: return True - def get_data(self, request): - return { - "main": { - "labels": ["Jan", "Feb", "Mar"], - "datasets": [ - { - "label": str(_("Orders")), - "data": [12, 19, 7], - "backgroundColor": "#2368A9", - } - ], - } - } + def get_queryset(self, request=None): + qs = super().get_queryset(request) + status = request.request_data.request_get.get("status") + if status: + qs = qs.filter(status=status) + return qs + def process_label(self, request, label, data, labels, dataset_data): + return label.strftime("%Y-%m") if label else _("No date") -class RevenueChartWidget(SBAdminDashboardChartWidget): - name = _("Revenue") - chart_type = "line" + +class ArticleAuthorChartWidget(SBAdminDashboardChartWidget): + name = _("Articles by author") + model = Article + chart_type = "bar" + x_axis_annotate = F("author__name") + y_axis_annotate = Count("id") + order_by = "x_axis" def has_view_permission(self, request, obj=None) -> bool: return True - def get_data(self, request): - return { - "main": { - "labels": ["Jan", "Feb", "Mar"], - "datasets": [ - { - "label": str(_("Revenue")), - "data": [1200, 1800, 900], - "borderColor": "#24B47E", - } - ], - } - } + def get_queryset(self, request=None): + qs = super().get_queryset(request) + status = request.request_data.request_get.get("status") + if status: + qs = qs.filter(status=status) + return qs ``` ```django -{# templates/dashboard/sales_dashboard.html #} +{# templates/dashboard/article_overview.html #} {% extends "sb_admin/dashboard/widget_base.html" %} {% load sb_admin_tags %} @@ -4645,42 +4712,39 @@ class RevenueChartWidget(SBAdminDashboardChartWidget): ``` ```python -# configuration.py -registered_views = [ - SBAdminDashboardView( - widgets=[ - SalesDashboardWidget( - sub_widgets=[ - OrdersChartWidget(), - RevenueChartWidget(), - ], - ), - ], - title=_("Dashboard"), - ), -] +SBAdminDashboardView( + widgets=[ + ArticleOverviewWidget( + sub_widgets=[ + ArticleMonthlyChartWidget(), + ArticleAuthorChartWidget(), + ], + ), + ], + title=_("Dashboard"), +) ``` -### 3. Widget With Subwidgets and Grouped AJAX +### 4. Real Subwidgets With One AJAX -Use `SBAdminDashboardGroupWidget` as the parent when several subwidgets should refresh from one parent AJAX response. This is useful for dashboards with one global filter form and multiple related child widgets. The parent calls each child `get_data(request)` and returns: +Use `SBAdminDashboardGroupWidget` when the parent is a group container and one parent AJAX response should refresh all children. The parent calls each child `get_data(request)` and returns: ```python { "sub_widget": { - "sales_dashboard_0": {...}, - "sales_dashboard_1": {...}, + "article_group_0": {...}, + "article_group_1": {...}, } } ``` -Child widgets still own how they consume their data: -- `SBAdminDashboardChartWidget` registers itself with the parent group and updates the chart from its data slice. -- `SBAdminDashboardHtmlWidget` registers itself with the parent group and replaces its own HTML from `{"html": "..."}`. -- Custom widgets can call `window.SBAdminRegisterDashboardSubWidget(parentWidgetId, {widgetId, onData})` from their own template. +Charts and HTML widgets already know how to register with the group and update themselves from their slice of the response. List widgets can render inside a group, but their Tabulator data still uses the list widget's own table AJAX endpoint. ```python -# dashboard_widgets.py +# blog/dashboard_widgets.py +from django.db.models import Count +from django.db.models.functions import TruncMonth +from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from django_smartbase_admin.engine.dashboard import ( @@ -4689,84 +4753,72 @@ from django_smartbase_admin.engine.dashboard import ( SBAdminDashboardHtmlWidget, ) from django_smartbase_admin.engine.field import SBAdminField -from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget, DateFilterWidget - - -class GroupedSalesDashboardWidget(SBAdminDashboardGroupWidget): - widget_id = "sales_dashboard" - name = _("Sales dashboard") - - def __init__(self, *args, **kwargs): - settings = [ - SBAdminField( - title=_("Date"), - name="created_at", - filter_widget=DateFilterWidget( - shortcuts=[ - {"value": [-30, 0], "label": _("Last 30 days")}, - {"value": [-90, 0], "label": _("Last 90 days")}, - ], - default_value_shortcut_index=0, - allow_clear=False, - ), - ), - SBAdminField( - title=_("Resolution"), - name="resolution", - filter_widget=ChoiceFilterWidget( - choices=[("day", _("Day")), ("month", _("Month"))], - default_value="month", - allow_clear=False, - ), +from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget + +from blog.models import Article + + +class ArticleGroupWidget(SBAdminDashboardGroupWidget): + widget_id = "article_group" + name = _("Article dashboard") + settings = [ + SBAdminField( + title=_("Status"), + name="status", + filter_widget=ChoiceFilterWidget( + choices=[ + ("", _("All")), + ("draft", _("Draft")), + ("published", _("Published")), + ("archived", _("Archived")), + ], + default_value="", ), - ] - super().__init__(*args, settings=settings, **kwargs) + ) + ] def has_view_permission(self, request, obj=None) -> bool: return True + def get_filtered_queryset(self, request): + qs = Article.objects.all() + status = request.request_data.request_get.get("status") + if status: + qs = qs.filter(status=status) + return qs + def get_data(self, request): - request.sales_dashboard_data = self.build_sales_dashboard_data(request) + request.article_group_queryset = self.get_filtered_queryset(request) return super().get_data(request) - def build_sales_dashboard_data(self, request): - resolution = request.request_data.request_get.get("resolution", "month") - if resolution == "day": - return { - "labels": ["Mon", "Tue", "Wed"], - "orders": [4, 9, 6], - "revenue_rows": [ - {"currency": "EUR", "amount": "450,00 €", "orders": 4}, - {"currency": "CZK", "amount": "2 100 Kč", "orders": 2}, - ], - } - return { - "labels": ["Jan", "Feb", "Mar"], - "orders": [12, 19, 7], - "revenue_rows": [ - {"currency": "EUR", "amount": "1 234,50 €", "orders": 23}, - {"currency": "CZK", "amount": "5 600 Kč", "orders": 8}, - {"currency": "HUF", "amount": "125 000 Ft", "orders": 4}, - ], - } - -class GroupedOrdersChartWidget(SBAdminDashboardChartWidget): - name = _("Orders") +class GroupedArticleMonthlyChartWidget(SBAdminDashboardChartWidget): + name = _("Articles by month") chart_type = "bar" def has_view_permission(self, request, obj=None) -> bool: return True def get_data(self, request): - dashboard_data = request.sales_dashboard_data + qs = getattr(request, "article_group_queryset", Article.objects.none()) + rows = ( + qs.annotate(month=TruncMonth("created_at")) + .values("month") + .annotate(total=Count("id")) + .order_by("month") + ) + labels = [ + row["month"].strftime("%Y-%m") if row["month"] else str(_("No date")) + for row in rows + ] + values = [row["total"] for row in rows] return { "main": { - "labels": dashboard_data["labels"], + "labels": labels, "datasets": [ { - "label": str(_("Orders")), - "data": dashboard_data["orders"], + "label": str(_("Articles")), + "data": values, "backgroundColor": "#2368A9", } ], @@ -4774,48 +4826,152 @@ class GroupedOrdersChartWidget(SBAdminDashboardChartWidget): } -class GroupedRevenueTableWidget(SBAdminDashboardHtmlWidget): - name = _("Revenue by currency") - content_template_name = "dashboard/revenue_table.html" +class GroupedArticleSummaryWidget(SBAdminDashboardHtmlWidget): + name = _("Summary") def has_view_permission(self, request, obj=None) -> bool: return True - def get_html_context_data(self, request): - dashboard_data = getattr(request, "sales_dashboard_data", None) - rows = dashboard_data["revenue_rows"] if dashboard_data else [] - return {"rows": rows} + def get_html(self, request): + qs = getattr(request, "article_group_queryset", Article.objects.none()) + return format_html( + '
{label}
' + '
{count}
', + label=_("Articles"), + count=qs.count(), + ) ``` -The example above stores shared data on `request` inside the parent `get_data()` before calling `super().get_data(request)`. This keeps child signatures unchanged (`get_data(request)` / `get_html_context_data(request)`) and lets a project do one service/repository call in the parent. Prefer a project-specific attribute name such as `request.sales_dashboard_data`; avoid broad names like `request.dashboard_data` when multiple grouped widgets can appear on the same page. +```python +SBAdminDashboardView( + widgets=[ + ArticleGroupWidget( + sub_widgets=[ + GroupedArticleMonthlyChartWidget(), + GroupedArticleSummaryWidget(), + ], + ), + ], + title=_("Dashboard"), +) +``` -`SBAdminDashboardHtmlWidget` also renders once during the initial page load, before the grouped AJAX request has called the parent `get_data()`. If the HTML child reads request-shared data, guard with `getattr(request, "...", None)` and return empty/sample/initial rows for the first render. The grouped AJAX response will replace the HTML afterwards. +**Grouped AJAX rules:** +- Put common settings/filters on the parent group. +- Child widgets should return their normal widget data shape. Chart widgets return `{"main": {"labels": ..., "datasets": ...}}`; HTML widgets return `{"html": "..."}`. +- If the parent preloads shared data on `request`, use a project-specific attribute such as `request.article_group_queryset` and guard child initial render with `getattr(...)`. +- Prefer this for chart/card/summary widgets. Use separate AJAX for expensive children or full list/table widgets. + +### Local Graph Settings + +Use `settings` for UI controls that affect how a dashboard widget displays data but are not ordinary queryset filters. Read them with `get_settings_from_request(request)`. ```python -# configuration.py -registered_views = [ - SBAdminDashboardView( - widgets=[ - GroupedSalesDashboardWidget( - sub_widgets=[ - GroupedOrdersChartWidget(), - GroupedRevenueTableWidget(), +# blog/dashboard_widgets.py +from django.db.models import Count +from django.db.models.functions import TruncDay, TruncMonth +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from django_smartbase_admin.engine.dashboard import SBAdminDashboardChartWidget +from django_smartbase_admin.engine.field import SBAdminField +from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget + +from blog.models import Article + + +class ArticleResolutionChartWidget(SBAdminDashboardChartWidget): + widget_id = "article_resolution_chart" + name = _("Articles") + model = Article + chart_type = "line" + y_axis_annotate = Count("id") + order_by = "x_axis" + RESOLUTION_KEY = "article_resolution" + settings = [ + SBAdminField( + title=_("Resolution"), + name=RESOLUTION_KEY, + filter_widget=ChoiceFilterWidget( + choices=[ + ("day", _("Day")), + ("month", _("Month")), ], + default_value="month", + allow_clear=False, ), - ], - title=_("Dashboard"), - ), -] + ) + ] + + def has_view_permission(self, request, obj=None) -> bool: + return True + + def get_resolution(self, request): + return self.get_settings_from_request(request).get(self.RESOLUTION_KEY) or "month" + + def get_x_axis_annotate(self, request): + if self.get_resolution(request) == "day": + return TruncDay("created_at") + return TruncMonth("created_at") + + def process_label(self, request, label, data, labels, dataset_data): + if not label: + return _("No date") + if self.get_resolution(request) == "day": + return label.strftime("%Y-%m-%d") + return label.strftime("%Y-%m") ``` -**Grouped AJAX rules:** -- Put global settings/filters on the parent widget. -- Pass widget instances in `sub_widgets`; do not use string import paths unless a project has a strong reason. -- Keep the group parent generic. It should aggregate child data, not know about charts, tables, calendars, or app-specific widgets. -- Each child widget owns its rendering/update contract. Charts return Chart.js-shaped data; HTML widgets return `{"html": rendered_html}`. -- `SBAdminDashboardListWidget` can be rendered inside a grouped parent, but it keeps its own table AJAX endpoint. Use grouped AJAX for summary/chart/static HTML widgets, not for full Tabulator list data. +For a child chart in the same module that has a local setting but should submit through the parent form together with common parent settings, retarget only that child's setting widgets after static initialization: + +```python +class ParentFormSettingMixin: + def init_widget_static(self, configuration): + super().init_widget_static(configuration) + if not self.parent_view: + return + for setting in self.get_settings(): + setting.filter_widget.view_id = self.parent_view.get_id() + + +class ArticleCostChartWidget(ParentFormSettingMixin, SBAdminDashboardChartWidget): + name = _("Cost") + model = Article + chart_type = "bar" + y_axis_annotate = Count("id") + order_by = "x_axis" + TIME_FRAME_KEY = "article_cost_time_frame" + settings = [ + SBAdminField( + title=_("Time frame"), + name=TIME_FRAME_KEY, + filter_widget=ChoiceFilterWidget( + choices=[ + ("year", _("Year")), + ("all", _("All")), + ], + default_value="year", + allow_clear=False, + ), + ) + ] + + def has_view_permission(self, request, obj=None) -> bool: + return True + + def get_x_axis_annotate(self, request): + return TruncMonth("created_at") + + def get_queryset(self, request=None): + qs = super().get_queryset(request) + if request.request_data.request_get.get(self.TIME_FRAME_KEY, "year") == "year": + qs = qs.filter(created_at__year=timezone.now().year) + return qs +``` + +Use this child-local setting pattern only when the parent renders one shared filter form and one child needs an extra control. Keep setting names unique (`article_cost_time_frame`, not `time_frame`) to avoid collisions between sibling widgets. -**Source:** `django_smartbase_admin/engine/dashboard.py`; `django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html`; `django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html`; `django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js` +**Source:** `django_smartbase_admin/engine/dashboard.py`; `django_smartbase_admin/templates/sb_admin/dashboard/widget_base.html`; `django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html`; `django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html`; `django_smartbase_admin/static/sb_admin/src/js/chart.js`; `django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js` --- From 295dedafcd7cb12666bc59947fc39f3e3600770e Mon Sep 17 00:00:00 2001 From: Viliam Mihalik Date: Fri, 19 Jun 2026 12:37:57 +0200 Subject: [PATCH 4/7] test(dashboard): add URL override and fix ajax_url logic in widget context --- src/django_smartbase_admin/engine/dashboard.py | 6 +++--- src/django_smartbase_admin/tests/test_dashboard.py | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/django_smartbase_admin/engine/dashboard.py b/src/django_smartbase_admin/engine/dashboard.py index 332c79ac..cac080f6 100644 --- a/src/django_smartbase_admin/engine/dashboard.py +++ b/src/django_smartbase_admin/engine/dashboard.py @@ -130,20 +130,20 @@ def action_get_data(self, request, modifier, object_id=None): return JsonResponse(data={"data": self.get_cached_data(request)}) def get_widget_context_data(self, request): + parent_group_widget = self.get_parent_group_widget() context = { "widget_id": self.get_id(), "widget_name": self.name, - "ajax_url": self.get_ajax_url(request), "filters": self.get_filters(), "settings": self.get_settings(), "sub_widgets": self.get_sub_widgets(), "request": request, "filter_form_id": self.get_filter_form_id(), } - parent_group_widget = self.get_parent_group_widget() if parent_group_widget: context["parent_widget_id"] = parent_group_widget.get_id() - context["parent_ajax_url"] = parent_group_widget.get_ajax_url(request) + else: + context["ajax_url"] = self.get_ajax_url(request) return context def get_sub_widgets(self): diff --git a/src/django_smartbase_admin/tests/test_dashboard.py b/src/django_smartbase_admin/tests/test_dashboard.py index ae9386d0..79d3f0fa 100644 --- a/src/django_smartbase_admin/tests/test_dashboard.py +++ b/src/django_smartbase_admin/tests/test_dashboard.py @@ -9,9 +9,11 @@ from django.db import models from django.db.models import F from django.template.loader import render_to_string -from django.test import RequestFactory, SimpleTestCase +from django.test import RequestFactory, SimpleTestCase, override_settings +from django.urls import path from django_smartbase_admin.actions.admin_action_list import SBAdminListAction from django_smartbase_admin.admin.admin_base import SBAdmin +from django_smartbase_admin.admin.site import sb_admin_site from django_smartbase_admin.engine.configuration import SBAdminRoleConfiguration from django_smartbase_admin.engine.const import FILTER_DATA_NAME, IGNORE_LIST_SELECTION from django_smartbase_admin.engine.dashboard import ( @@ -26,6 +28,8 @@ from django_smartbase_admin.engine.field import SBAdminField from django_smartbase_admin.views.dashboard_view import SBAdminDashboardView +urlpatterns = [path("", sb_admin_site.urls)] + class _DashboardWidget(SBAdminDashboardListWidget): model = User @@ -252,6 +256,7 @@ def filter_queryset_by_parent_instance_ids( return queryset.filter(id__in=parent_instance_ids) +@override_settings(ROOT_URLCONF=__name__) class TestSBAdminDashboardListWidget(SimpleTestCase): def setUp(self): self.factory = RequestFactory() From 143b8cef3d3c9586b52af4916bb8efabe206429b Mon Sep 17 00:00:00 2001 From: Viliam Mihalik Date: Fri, 19 Jun 2026 12:46:44 +0200 Subject: [PATCH 5/7] triv: bump version to 2.0.10b2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 65756d20..9736db1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-smartbase-admin" -version = "2.0.10b1" +version = "2.0.10b2" description = "" authors = ["SmartBase "] readme = "README.md" From d81d80a4802a9cdcd8974b661af6f853196059a1 Mon Sep 17 00:00:00 2001 From: Viliam Mihalik Date: Fri, 19 Jun 2026 17:44:13 +0200 Subject: [PATCH 6/7] feat(dashboard): add parent filter module and widget support Integrated `DashboardParentFilterModule` for hierarchical filtering in widgets, supporting parent-child data relationships. Updated templates, JavaScript, and admin logic to enable dynamic subwidget filtering and improved widget registration. Extended tests and constants for additional functionality. --- .../actions/admin_action_list.py | 7 ++- .../engine/admin_base_view.py | 5 +- src/django_smartbase_admin/engine/const.py | 1 + .../engine/dashboard.py | 23 ++++++- .../static/sb_admin/src/js/chart.js | 1 - .../static/sb_admin/src/js/dashboard_group.js | 56 +++++++++-------- .../static/sb_admin/src/js/table.js | 3 + .../dashboard_parent_filter_module.js | 38 ++++++++++++ .../templates/sb_admin/actions/dashboard.html | 5 +- .../sb_admin/dashboard/chart_widget.html | 55 ++++++++--------- .../sb_admin/dashboard/html_widget.html | 4 -- .../tests/test_dashboard.py | 61 ++++++++++++++++++- 12 files changed, 192 insertions(+), 67 deletions(-) create mode 100644 src/django_smartbase_admin/static/sb_admin/src/js/table_modules/dashboard_parent_filter_module.js diff --git a/src/django_smartbase_admin/actions/admin_action_list.py b/src/django_smartbase_admin/actions/admin_action_list.py index ee2b9eaf..c33de4a9 100644 --- a/src/django_smartbase_admin/actions/admin_action_list.py +++ b/src/django_smartbase_admin/actions/admin_action_list.py @@ -37,6 +37,7 @@ TABLE_PARAMS_FULL_TEXT_SEARCH, TABLE_PARAMS_SELECTED_FILTER_TYPE, ADVANCED_FILTER_DATA_NAME, + PARENT_FILTER_DATA_NAME, IGNORE_LIST_SELECTION, MODIFIER_OBJECT_ID, SB_ADMIN_AJAX_NOTIFICATIONS_KEY, @@ -198,6 +199,7 @@ def get_template_data(self): "TABLE_PARAMS_SELECTED_FILTER_TYPE": TABLE_PARAMS_SELECTED_FILTER_TYPE, "FILTER_DATA_NAME": FILTER_DATA_NAME, "ADVANCED_FILTER_DATA_NAME": ADVANCED_FILTER_DATA_NAME, + "PARENT_FILTER_DATA_NAME": PARENT_FILTER_DATA_NAME, "BASE_PARAMS_NAME": BASE_PARAMS_NAME, "TABLE_PARAMS_PAGE_NAME": TABLE_PARAMS_PAGE_NAME, "TABLE_PARAMS_SORT_NAME": TABLE_PARAMS_SORT_NAME, @@ -303,7 +305,10 @@ def get_filter_from_request(self): self.threadsafe_request, self.column_fields, self.filter_data ) advanced_filters = QueryBuilderService.get_filters_for_list_action(self) - return base_filters & advanced_filters + extra_filters = self.view.get_extra_filter_from_request( + self.threadsafe_request, self + ) + return base_filters & advanced_filters & extra_filters def get_search_fields(self, request): search_fields_definition = self.view.get_search_fields(request) diff --git a/src/django_smartbase_admin/engine/admin_base_view.py b/src/django_smartbase_admin/engine/admin_base_view.py index ee20a2fa..546c9d67 100644 --- a/src/django_smartbase_admin/engine/admin_base_view.py +++ b/src/django_smartbase_admin/engine/admin_base_view.py @@ -10,7 +10,7 @@ from django.contrib import messages from django.contrib.admin.actions import delete_selected from django.core.exceptions import ImproperlyConfigured, PermissionDenied -from django.db.models import F +from django.db.models import F, Q from django.http import HttpResponse, JsonResponse, HttpRequest, Http404 from django.shortcuts import redirect from django.template.response import TemplateResponse @@ -673,6 +673,9 @@ class SBAdminBaseListView(SBAdminBaseView): def get_list_view_media(self, request): return forms.Media(js=("sb_admin/dist/table.js",)) + def get_extra_filter_from_request(self, request, list_action): + return Q() + @classmethod def _postgres_unaccent_extension_available(cls) -> bool: from django.conf import settings diff --git a/src/django_smartbase_admin/engine/const.py b/src/django_smartbase_admin/engine/const.py index c7300a93..7a86b28d 100644 --- a/src/django_smartbase_admin/engine/const.py +++ b/src/django_smartbase_admin/engine/const.py @@ -59,6 +59,7 @@ class FilterVersions(Enum): TABLE_UPDATE_ROW_DATA_EVENT_NAME = "SBAdminUpdateRowData" FILTER_DATA_NAME = "filterData" ADVANCED_FILTER_DATA_NAME = "advancedFilterData" +PARENT_FILTER_DATA_NAME = "parentFilterData" BASE_PARAMS_NAME = "params" AUTOCOMPLETE_SEARCH_NAME = "__search_term__" AUTOCOMPLETE_FORWARD_NAME = "__forward_data__" diff --git a/src/django_smartbase_admin/engine/dashboard.py b/src/django_smartbase_admin/engine/dashboard.py index cac080f6..e84a3ed8 100644 --- a/src/django_smartbase_admin/engine/dashboard.py +++ b/src/django_smartbase_admin/engine/dashboard.py @@ -5,7 +5,7 @@ from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured from django.db import models -from django.db.models import QuerySet +from django.db.models import Q, QuerySet from django.db.models.functions import TruncDay, TruncMonth, TruncWeek, TruncYear from django.http import JsonResponse from django.template.loader import render_to_string @@ -15,7 +15,10 @@ from django_smartbase_admin.engine.actions import sbadmin_action from django_smartbase_admin.engine.admin_base_view import SBAdminBaseListView from django_smartbase_admin.engine.admin_view import SBAdminView -from django_smartbase_admin.engine.const import OBJECT_ID_PLACEHOLDER +from django_smartbase_admin.engine.const import ( + OBJECT_ID_PLACEHOLDER, + PARENT_FILTER_DATA_NAME, +) from django_smartbase_admin.engine.field import SBAdminField from django_smartbase_admin.engine.filter_widgets import ( DateFilterWidget, @@ -791,6 +794,17 @@ def get_queryset(self, request=None): qs = super().get_queryset(request) return self._filter_queryset_by_parent_request(request, qs) + def get_extra_filter_from_request(self, request, list_action): + return self.get_filter_from_dashboard_filter( + request, list_action.params.get(PARENT_FILTER_DATA_NAME, {}) + ) + + def get_filter_from_dashboard_filter(self, request, dashboard_filter_data): + return Q() + + def get_data(self, request): + return {} + def init_view_dynamic(self, request, request_data=None, **kwargs): super().init_view_dynamic(request, request_data, **kwargs) self.init_fields_cache( @@ -816,6 +830,9 @@ def get_tabulator_header_template_name(self, request) -> str: def get_tabulator_definition(self, request): tabulator_definition = super().get_tabulator_definition(request) + parent_group_widget = self.get_parent_group_widget() + if parent_group_widget: + tabulator_definition["parentWidgetId"] = parent_group_widget.get_id() tabulator_definition["stickyHeaderAndFooter"] = False tabulator_definition["modules"] = [ "viewsModule", @@ -824,6 +841,8 @@ def get_tabulator_definition(self, request): "filterModule", "columnDisplayModule", ] + if parent_group_widget: + tabulator_definition["modules"].append("dashboardParentFilterModule") return tabulator_definition diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/chart.js b/src/django_smartbase_admin/static/sb_admin/src/js/chart.js index d6ddecf6..718b3d28 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/js/chart.js +++ b/src/django_smartbase_admin/static/sb_admin/src/js/chart.js @@ -152,4 +152,3 @@ class SBAdminChart { } window.SBAdminChartClass = SBAdminChart -document.dispatchEvent(new Event('SBAdminChartClassLoaded')) diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js b/src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js index dee4dc47..a7d994dd 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js +++ b/src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js @@ -6,8 +6,9 @@ class SBAdminDashboardGroup { this.groupId = element.dataset.dashboardGroupId this.formId = element.dataset.filterFormId this.ajaxUrl = element.dataset.ajaxUrl - this.subWidgets = {} + this.subWidgets = new Map() this.lastData = null + this.refreshCount = 0 this.initialized = false } @@ -30,7 +31,10 @@ class SBAdminDashboardGroup { return values } - updateSubWidget(definition, responseData) { + updateSubWidget(definition, responseData, isInitialData = false) { + if (isInitialData && definition.skipInitialData) { + return + } const widgetData = responseData.sub_widget[definition.widgetId] if (!widgetData || !definition.onData) { return @@ -39,21 +43,23 @@ class SBAdminDashboardGroup { } refresh() { + const isInitialData = this.refreshCount === 0 fetch(`${this.ajaxUrl}?${new URLSearchParams(this.formValues())}`, { method: 'GET', headers: {"X-CSRFToken": window.csrf_token}, }).then(response => response.json()).then(response => { this.lastData = response.data - Object.values(this.subWidgets).forEach((definition) => { - this.updateSubWidget(definition, this.lastData) + this.refreshCount += 1 + this.subWidgets.forEach((definition) => { + this.updateSubWidget(definition, this.lastData, isInitialData) }) }) } registerSubWidget(definition) { - this.subWidgets[definition.widgetId] = definition + this.subWidgets.set(definition.widgetId, definition) if (this.lastData) { - this.updateSubWidget(definition, this.lastData) + this.updateSubWidget(definition, this.lastData, this.refreshCount === 1) } } @@ -74,36 +80,38 @@ class SBAdminDashboardGroup { } } +const registeredSubWidgets = new Map() + +function getRegisteredSubWidgets(groupId) { + if (!registeredSubWidgets.has(groupId)) { + registeredSubWidgets.set(groupId, new Map()) + } + return registeredSubWidgets.get(groupId) +} + function initDashboardGroups() { window.SBAdminDashboardGroups = window.SBAdminDashboardGroups || {} document.querySelectorAll('[data-dashboard-group-id]').forEach((element) => { const groupId = element.dataset.dashboardGroupId - const group = window.SBAdminDashboardGroups[groupId] || new SBAdminDashboardGroup(element) - window.SBAdminDashboardGroups[groupId] = group - const pendingDefinitions = window.SBAdminDashboardGroupPendingSubWidgets[groupId] || [] - pendingDefinitions.forEach((definition) => { + const group = new SBAdminDashboardGroup(element) + // Child widgets usually register while the group HTML is rendering. + // Init copies those callbacks into the live group before the first shared AJAX refresh. + getRegisteredSubWidgets(group.groupId).forEach((definition) => { group.registerSubWidget(definition) }) - window.SBAdminDashboardGroupPendingSubWidgets[groupId] = [] + window.SBAdminDashboardGroups[groupId] = group group.init() }) } -window.SBAdminDashboardGroups = window.SBAdminDashboardGroups || {} -window.SBAdminDashboardGroupPendingSubWidgets = window.SBAdminDashboardGroupPendingSubWidgets || {} window.SBAdminRegisterDashboardSubWidget = function(groupId, definition) { - const group = window.SBAdminDashboardGroups[groupId] + // Keep every child callback in the registration map so init can wire it once the group exists. + getRegisteredSubWidgets(groupId).set(definition.widgetId, definition) + const group = window.SBAdminDashboardGroups && window.SBAdminDashboardGroups[groupId] if (group) { + // Some widgets, especially tables, initialize after the group. Attach them immediately too. + // registerSubWidget de-duplicates by widgetId through the group's Map. group.registerSubWidget(definition) - return } - window.SBAdminDashboardGroupPendingSubWidgets[groupId] = window.SBAdminDashboardGroupPendingSubWidgets[groupId] || [] - window.SBAdminDashboardGroupPendingSubWidgets[groupId].push(definition) -} - -if (window.SBAdminMainLoaded) { - initDashboardGroups() -} else { - document.addEventListener('SBAdminMainLoaded', initDashboardGroups, {once: true}) } -document.dispatchEvent(new Event('SBAdminDashboardGroupLoaded')) +window.SBAdminInitDashboardGroups = initDashboardGroups 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 e30933aa..557a7ad6 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 @@ -12,6 +12,7 @@ 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 { DashboardParentFilterModule } from "./table_modules/dashboard_parent_filter_module" import { SBAjaxParamsTabulatorModifier } from "./sb_ajax_params_tabulator_modifier" import { createIcon } from "./utils" import { registerFitDataFillAvailableSpaceLayout } from "./tabulator_layouts/fit_data_fill_available_space" @@ -47,6 +48,7 @@ class SBAdminTable { this.tableDataEditUrl = options.tableDataEditUrl this.tableActionMoveUrl = options.tableActionMoveUrl this.tableDetailUrl = options.tableDetailUrl + this.parentWidgetId = options.parentWidgetId this.defaultColumnData = options.defaultColumnData this.tableColumns = this.initDefaultColumns(options.tableColumns) this.tableIdColumnName = options.tableIdColumnName @@ -575,4 +577,5 @@ window.SBAdminTableModulesClass = { 'headerTabsModule': HeaderTabsModule, 'dataTreeModule': DataTreeModule, 'stickyHeaderAndFooterModule': StickyHeaderAndFooterModule, + 'dashboardParentFilterModule': DashboardParentFilterModule, } diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/dashboard_parent_filter_module.js b/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/dashboard_parent_filter_module.js new file mode 100644 index 00000000..43b31db6 --- /dev/null +++ b/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/dashboard_parent_filter_module.js @@ -0,0 +1,38 @@ +import { SBAdminTableModule } from "./base_module" + +export class DashboardParentFilterModule extends SBAdminTableModule { + getParentGroup() { + return window.SBAdminDashboardGroups?.[this.table.parentWidgetId] + } + + formValues() { + const group = this.getParentGroup() + return group ? group.formValues() : {} + } + + getUrlParams() { + if (!this.table.parentWidgetId) { + return {} + } + const values = this.formValues() + if (Object.keys(values).length === 0) { + return {} + } + return { + [this.table.constants.PARENT_FILTER_DATA_NAME]: values, + } + } + + afterInit() { + if (!this.table.parentWidgetId) { + return + } + window.SBAdminRegisterDashboardSubWidget(this.table.parentWidgetId, { + widgetId: this.table.viewId, + skipInitialData: true, + onData: () => { + this.table.tabulator.setData() + }, + }) + } +} diff --git a/src/django_smartbase_admin/templates/sb_admin/actions/dashboard.html b/src/django_smartbase_admin/templates/sb_admin/actions/dashboard.html index 4e0ddccf..6e4fe14a 100644 --- a/src/django_smartbase_admin/templates/sb_admin/actions/dashboard.html +++ b/src/django_smartbase_admin/templates/sb_admin/actions/dashboard.html @@ -7,14 +7,17 @@ {% endblock %} {% block content %} + {{ dashboard_media.js }}
{% for widget in direct_sub_views %} {% render_widget widget request %} {% endfor %}
+ {% endblock %} {% block additional_js %} {{ block.super }} - {{ dashboard_media.js }} {% endblock %} diff --git a/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html b/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html index 5e7b0b47..d5560e8d 100644 --- a/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html +++ b/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html @@ -15,42 +15,35 @@ {{ initial_data|get_json_script:'initial_data' }} diff --git a/src/django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html b/src/django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html index 8225c712..b962dde0 100644 --- a/src/django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html +++ b/src/django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html @@ -13,10 +13,6 @@