dashboard: группировка виджетов по ширине (½ / полная)
Каждому виджету можно задать ширину: «Полная» (на всю строку) или «½».
Виджеты выстраиваются в flex-сетку: два «½» встают в один ряд, «Полная»
занимает строку целиком. На узких экранах половинные переносятся в столбик.
- DashboardLayout.widget_widths (JSON {ключ: half|full}) + миграция 0022;
поле widgets не тронуто — обратная совместимость сохранена
- core/dashboard: layout_widths, ширина в build_dashboard_context/
customize_rows, save_layout(widths=...), normalize_width
- dashboard.html: flex-wrap сетка по ширине; customize: переключатель ½/полная
- tests: 6 (сохранение/чтение ширин, нормализация, дефолт, контекст, POST)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+33
-6
@@ -170,6 +170,14 @@ WIDGETS_BY_KEY = {w.key: w for w in DASHBOARD_WIDGETS}
|
||||
# Полный набор в порядке реестра (fallback, если ничего не настроено).
|
||||
DEFAULT_WIDGET_KEYS = [w.key for w in DASHBOARD_WIDGETS]
|
||||
|
||||
# Ширина виджета в сетке дашборда.
|
||||
WIDGET_WIDTHS = ('full', 'half')
|
||||
DEFAULT_WIDTH = 'full'
|
||||
|
||||
|
||||
def normalize_width(value) -> str:
|
||||
return value if value in WIDGET_WIDTHS else DEFAULT_WIDTH
|
||||
|
||||
|
||||
def valid_keys(keys) -> list:
|
||||
"""Только существующие ключи, без дублей, с сохранением порядка."""
|
||||
@@ -221,9 +229,17 @@ def resolve_layout_keys(user) -> list:
|
||||
return [k for k in default if k in allowed]
|
||||
|
||||
|
||||
def layout_widths(user) -> dict:
|
||||
"""Сохранённые ширины виджетов пользователя ({ключ: 'half'|'full'})."""
|
||||
from core.models import DashboardLayout
|
||||
layout = DashboardLayout.objects.filter(employee=user).first()
|
||||
return dict(layout.widget_widths or {}) if layout else {}
|
||||
|
||||
|
||||
def build_dashboard_context(request) -> dict:
|
||||
"""Плоский контекст всех включённых виджетов + список для отрисовки."""
|
||||
keys = resolve_layout_keys(request.user)
|
||||
widths = layout_widths(request.user)
|
||||
merged: dict = {}
|
||||
widgets = []
|
||||
for key in keys:
|
||||
@@ -235,6 +251,7 @@ def build_dashboard_context(request) -> dict:
|
||||
'key': widget.key,
|
||||
'label': widget.label,
|
||||
'template': widget.template,
|
||||
'width': normalize_width(widths.get(key, DEFAULT_WIDTH)),
|
||||
})
|
||||
merged['dashboard_widgets'] = widgets
|
||||
merged['dashboard_has_custom_layout'] = _has_custom_layout(request.user)
|
||||
@@ -250,27 +267,37 @@ def customize_rows(user) -> list:
|
||||
"""Строки для страницы настройки: включённые (в порядке) + выключенные.
|
||||
|
||||
Только доступные пользователю виджеты — нельзя включить то, к чему нет
|
||||
доступа.
|
||||
доступа. У каждой строки есть ширина ('half'|'full').
|
||||
"""
|
||||
allowed = set(accessible_keys(user))
|
||||
widths = layout_widths(user)
|
||||
enabled = resolve_layout_keys(user)
|
||||
enabled_set = set(enabled)
|
||||
rows = [{'widget': WIDGETS_BY_KEY[k], 'enabled': True} for k in enabled]
|
||||
rows = [
|
||||
{'widget': WIDGETS_BY_KEY[k], 'enabled': True,
|
||||
'width': normalize_width(widths.get(k, DEFAULT_WIDTH))}
|
||||
for k in enabled
|
||||
]
|
||||
for widget in DASHBOARD_WIDGETS:
|
||||
if widget.key in allowed and widget.key not in enabled_set:
|
||||
rows.append({'widget': widget, 'enabled': False})
|
||||
rows.append({
|
||||
'widget': widget, 'enabled': False,
|
||||
'width': normalize_width(widths.get(widget.key, DEFAULT_WIDTH)),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def save_layout(user, ordered_keys, enabled_keys) -> list:
|
||||
"""Сохранить раскладку: включённые и доступные ключи в заданном порядке."""
|
||||
def save_layout(user, ordered_keys, enabled_keys, widths=None) -> list:
|
||||
"""Сохранить раскладку: включённые и доступные ключи в заданном порядке
|
||||
+ ширины (widths: {ключ: 'half'|'full'})."""
|
||||
from core.models import DashboardLayout
|
||||
|
||||
allowed = set(accessible_keys(user))
|
||||
enabled_set = set(enabled_keys)
|
||||
result = [k for k in valid_keys(ordered_keys) if k in enabled_set and k in allowed]
|
||||
width_map = {k: normalize_width((widths or {}).get(k, DEFAULT_WIDTH)) for k in result}
|
||||
DashboardLayout.objects.update_or_create(
|
||||
employee=user, defaults={'widgets': result},
|
||||
employee=user, defaults={'widgets': result, 'widget_widths': width_map},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-09-22 07:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0021_dashboarddefaults_dashboardlayout'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='dashboardlayout',
|
||||
name='widget_widths',
|
||||
field=models.JSONField(blank=True, default=dict, verbose_name='Ширины виджетов'),
|
||||
),
|
||||
]
|
||||
@@ -16,6 +16,8 @@ class DashboardLayout(models.Model):
|
||||
related_name='dashboard_layout', verbose_name=_('Сотрудник'),
|
||||
)
|
||||
widgets = models.JSONField(_('Виджеты (порядок)'), default=list, blank=True)
|
||||
# {ключ виджета: 'half' | 'full'} — ширина виджета в сетке дашборда.
|
||||
widget_widths = models.JSONField(_('Ширины виджетов'), default=dict, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -93,6 +93,58 @@ class DashboardLayoutLogicTests(TestCase):
|
||||
self.assertFalse(rows[2]['enabled'])
|
||||
|
||||
|
||||
class DashboardWidthTests(TestCase):
|
||||
def _ctx(self, user):
|
||||
from django.test import RequestFactory
|
||||
req = RequestFactory().get('/dashboard/')
|
||||
req.user = user
|
||||
return dash.build_dashboard_context(req)
|
||||
|
||||
def test_save_and_read_widths(self):
|
||||
user = make_employee()
|
||||
dash.save_layout(user, ['welcome', 'news'], ['welcome', 'news'],
|
||||
widths={'welcome': 'half', 'news': 'half'})
|
||||
self.assertEqual(dash.layout_widths(user), {'welcome': 'half', 'news': 'half'})
|
||||
self.assertEqual(DashboardLayout.objects.get(employee=user).widget_widths,
|
||||
{'welcome': 'half', 'news': 'half'})
|
||||
|
||||
def test_invalid_width_normalized_to_full(self):
|
||||
user = make_employee()
|
||||
dash.save_layout(user, ['news'], ['news'], widths={'news': 'bogus'})
|
||||
self.assertEqual(dash.layout_widths(user), {'news': 'full'})
|
||||
|
||||
def test_default_width_is_full(self):
|
||||
user = make_employee()
|
||||
widgets = self._ctx(user)['dashboard_widgets']
|
||||
self.assertTrue(widgets)
|
||||
self.assertTrue(all(w['width'] == 'full' for w in widgets))
|
||||
|
||||
def test_context_reflects_saved_width(self):
|
||||
user = make_employee()
|
||||
dash.save_layout(user, ['welcome', 'news'], ['welcome', 'news'],
|
||||
widths={'welcome': 'half', 'news': 'full'})
|
||||
by_key = {w['key']: w for w in self._ctx(user)['dashboard_widgets']}
|
||||
self.assertEqual(by_key['welcome']['width'], 'half')
|
||||
self.assertEqual(by_key['news']['width'], 'full')
|
||||
|
||||
def test_customize_rows_carry_width(self):
|
||||
user = make_employee()
|
||||
dash.save_layout(user, ['news'], ['news'], widths={'news': 'half'})
|
||||
row = next(r for r in dash.customize_rows(user) if r['widget'].key == 'news')
|
||||
self.assertEqual(row['width'], 'half')
|
||||
|
||||
def test_customize_post_saves_width(self):
|
||||
user = make_employee()
|
||||
login_2fa(self.client, user)
|
||||
resp = self.client.post('/dashboard/customize/', {
|
||||
'order': 'welcome,news', 'enabled': ['welcome', 'news'],
|
||||
'width_welcome': 'half', 'width_news': 'full',
|
||||
}, HTTP_HX_REQUEST='true')
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.assertEqual(DashboardLayout.objects.get(employee=user).widget_widths,
|
||||
{'welcome': 'half', 'news': 'full'})
|
||||
|
||||
|
||||
class DashboardWidgetAccessTests(TestCase):
|
||||
def test_metrics_hidden_without_reports(self):
|
||||
user = make_employee()
|
||||
|
||||
+2
-1
@@ -79,7 +79,8 @@ def dashboard_customize_view(request):
|
||||
enabled = request.POST.getlist('enabled')
|
||||
if not order: # JS отключён — берём порядок чекбоксов
|
||||
order = enabled
|
||||
dash.save_layout(request.user, order, enabled)
|
||||
widths = {k: request.POST.get(f'width_{k}', dash.DEFAULT_WIDTH) for k in enabled}
|
||||
dash.save_layout(request.user, order, enabled, widths=widths)
|
||||
return redirect('dashboard')
|
||||
|
||||
context = {'rows': dash.customize_rows(request.user)}
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 1.5rem;">
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: flex-start;">
|
||||
{% for w in dashboard_widgets %}
|
||||
{% include w.template %}
|
||||
<div style="{% if w.width == 'half' %}flex: 1 1 calc(50% - 0.75rem); min-width: min(100%, 22rem);{% else %}flex: 1 1 100%; min-width: 0;{% endif %}">
|
||||
{% include w.template %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div style="padding: 2rem; text-align: center; color: var(--color-muted-foreground); font-size: 0.875rem;">
|
||||
<div style="flex: 1 1 100%; padding: 2rem; text-align: center; color: var(--color-muted-foreground); font-size: 0.875rem;">
|
||||
{% trans "Все виджеты скрыты." %}
|
||||
<a href="/dashboard/customize/" hx-get="/dashboard/customize/" hx-target="#main-content" hx-push-url="/dashboard/customize/" hx-indicator="#loader" style="color: var(--color-primary);">{% trans "Настроить дашборд" %}</a>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 1.5rem; font-weight: 700; color: var(--color-foreground); margin-bottom: 0.25rem;">{% trans "Настройка дашборда" %}</h1>
|
||||
<p style="font-size: 0.875rem; color: var(--color-muted-foreground);">{% trans "Включите нужные виджеты и расположите их в удобном порядке." %}</p>
|
||||
<p style="font-size: 0.875rem; color: var(--color-muted-foreground);">{% trans "Включите нужные виджеты, задайте ширину и порядок. Два виджета «½» встают в один ряд, «Полная» — на всю ширину." %}</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary" style="border: 1px solid var(--color-border); font-size: 0.8125rem; white-space: nowrap;"
|
||||
hx-get="/dashboard/" hx-target="#main-content" hx-push-url="/dashboard/" hx-indicator="#loader">← {% trans "К дашборду" %}</button>
|
||||
@@ -27,6 +27,14 @@
|
||||
<span style="display: block; font-size: 0.8125rem; color: var(--color-muted-foreground); margin-top: 0.125rem;">{{ row.widget.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div style="display: inline-flex; flex-shrink: 0; border: 1px solid var(--color-border); border-radius: 0.375rem; overflow: hidden; font-size: 0.75rem;">
|
||||
<label style="padding: 0.25rem 0.5rem; cursor: pointer; color: var(--color-muted-foreground);">
|
||||
<input type="radio" name="width_{{ row.widget.key }}" value="full" {% if row.width == 'full' %}checked{% endif %} style="margin-right: 0.2rem;">{% trans "Полная" %}
|
||||
</label>
|
||||
<label style="padding: 0.25rem 0.5rem; cursor: pointer; color: var(--color-muted-foreground); border-left: 1px solid var(--color-border);">
|
||||
<input type="radio" name="width_{{ row.widget.key }}" value="half" {% if row.width == 'half' %}checked{% endif %} style="margin-right: 0.2rem;">½
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.25rem; flex-shrink: 0;">
|
||||
<button type="button" class="btn btn-ghost btn-icon" title="{% trans 'Выше' %}" onclick="dashMove(this, -1)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m18 15-6-6-6 6"/></svg>
|
||||
|
||||
Reference in New Issue
Block a user