feat: Enhance Dashboard widget configuration and layout management
- Added validation for widget configuration in the DashboardWidgetEntry model to ensure proper data structure. - Updated the DashboardLayoutPayload to include widget configuration in the serialized output. - Improved the PilotBodySection and DashboardLabPage components to support dynamic chart days configuration for the body overview widget. - Refactored layout editor functions to normalize widget configurations for better handling. - Bumped app_dashboard version to 1.2.0 to reflect the new features and improvements.
This commit is contained in:
parent
f6c5f96768
commit
87c4cbc4b4
|
|
@ -7,8 +7,9 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from dashboard_widget_config import validate_widget_entry_config
|
||||
from widget_catalog import ALLOWED_WIDGET_IDS, WIDGET_CATALOG
|
||||
|
||||
# Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul)
|
||||
|
|
@ -31,6 +32,21 @@ def default_layout_dict() -> dict[str, Any]:
|
|||
class DashboardWidgetEntry(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=64)
|
||||
enabled: bool = True
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("config", mode="before")
|
||||
@classmethod
|
||||
def _config_coerce(cls, v: Any) -> dict[str, Any]:
|
||||
if v is None:
|
||||
return {}
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("config muss Objekt sein")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _normalize_widget_config(self) -> DashboardWidgetEntry:
|
||||
normalized = validate_widget_entry_config(self.id, self.config)
|
||||
return self.model_copy(update={"config": normalized})
|
||||
|
||||
|
||||
class DashboardLayoutPayload(BaseModel):
|
||||
|
|
@ -50,10 +66,13 @@ class DashboardLayoutPayload(BaseModel):
|
|||
return self
|
||||
|
||||
def to_stored_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"version": self.version,
|
||||
"widgets": [{"id": w.id, "enabled": w.enabled} for w in self.widgets],
|
||||
}
|
||||
out_widgets: list[dict[str, Any]] = []
|
||||
for w in self.widgets:
|
||||
d: dict[str, Any] = {"id": w.id, "enabled": w.enabled}
|
||||
if w.config:
|
||||
d["config"] = dict(w.config)
|
||||
out_widgets.append(d)
|
||||
return {"version": self.version, "widgets": out_widgets}
|
||||
|
||||
|
||||
def coalesce_effective_layout(raw: Any) -> tuple[bool, dict[str, Any]]:
|
||||
|
|
|
|||
64
backend/dashboard_widget_config.py
Normal file
64
backend/dashboard_widget_config.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
Pro-Widget-Konfiguration im Dashboard-Layout (v1).
|
||||
|
||||
Nur ausgewählte Widget-IDs dürfen nicht-leere config haben; bekannte Keys werden validiert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
MAX_WIDGET_CONFIG_JSON_BYTES = 1024
|
||||
|
||||
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview"})
|
||||
|
||||
|
||||
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
||||
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
|
||||
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
|
||||
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES:
|
||||
raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)")
|
||||
if not raw:
|
||||
return {}
|
||||
|
||||
if widget_id not in WIDGETS_ALLOWING_CONFIG:
|
||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||
|
||||
if widget_id == "body_overview":
|
||||
return _validate_body_overview_config(raw)
|
||||
|
||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||
|
||||
|
||||
def _validate_body_overview_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
allowed = frozenset({"chart_days"})
|
||||
unknown = set(raw) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"body_overview: unbekannte config-Felder: {sorted(unknown)}")
|
||||
out: dict[str, Any] = {}
|
||||
if "chart_days" not in raw:
|
||||
return out
|
||||
v = raw["chart_days"]
|
||||
if isinstance(v, bool):
|
||||
raise ValueError("body_overview: chart_days muss ganze Zahl sein")
|
||||
if isinstance(v, float):
|
||||
if not math.isfinite(v):
|
||||
raise ValueError("body_overview: chart_days muss ganze Zahl sein")
|
||||
if abs(v - round(v)) > 1e-9:
|
||||
raise ValueError("body_overview: chart_days muss ganze Zahl sein")
|
||||
v = int(round(v))
|
||||
elif isinstance(v, int):
|
||||
pass
|
||||
else:
|
||||
raise ValueError("body_overview: chart_days muss ganze Zahl sein")
|
||||
if v < 7 or v > 90:
|
||||
raise ValueError("body_overview: chart_days muss zwischen 7 und 90 liegen")
|
||||
out["chart_days"] = v
|
||||
return out
|
||||
54
backend/tests/test_dashboard_widget_config.py
Normal file
54
backend/tests/test_dashboard_widget_config.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import pytest
|
||||
|
||||
from dashboard_layout_schema import DashboardLayoutPayload, coalesce_effective_layout, default_layout_dict
|
||||
from dashboard_widget_config import validate_widget_entry_config
|
||||
|
||||
|
||||
def test_body_chart_days_bounds():
|
||||
assert validate_widget_entry_config("body_overview", {"chart_days": 7}) == {"chart_days": 7}
|
||||
assert validate_widget_entry_config("body_overview", {"chart_days": 90}) == {"chart_days": 90}
|
||||
assert validate_widget_entry_config("body_overview", {"chart_days": 42.0}) == {"chart_days": 42}
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("body_overview", {"chart_days": 6})
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("body_overview", {"chart_days": 91})
|
||||
|
||||
|
||||
def test_welcome_config_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("welcome", {"x": 1})
|
||||
|
||||
|
||||
def test_body_unknown_key():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("body_overview", {"chart_days": 30, "extra": 1})
|
||||
|
||||
|
||||
def test_layout_payload_with_chart_days_roundtrip():
|
||||
p = DashboardLayoutPayload.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"widgets": [
|
||||
{"id": "welcome", "enabled": True},
|
||||
{
|
||||
"id": "body_overview",
|
||||
"enabled": True,
|
||||
"config": {"chart_days": 42},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
d = p.to_stored_dict()
|
||||
assert d["widgets"][1]["config"]["chart_days"] == 42
|
||||
|
||||
|
||||
def test_coalesce_rejects_invalid_widget_config():
|
||||
raw = {
|
||||
"version": 1,
|
||||
"widgets": [
|
||||
{"id": "welcome", "enabled": True, "config": {"evil": True}},
|
||||
],
|
||||
}
|
||||
custom, eff = coalesce_effective_layout(raw)
|
||||
assert custom is False
|
||||
assert eff == default_layout_dict()
|
||||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
|||
"importdata": "1.0.0",
|
||||
"membership": "2.1.0",
|
||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
||||
"app_dashboard": "1.1.0", # Dashboard-Lab + GET /widgets/catalog (Widget-System Iteration 1)
|
||||
"app_dashboard": "1.2.0", # Widget-Config (body_overview.chart_days) + Validierung
|
||||
}
|
||||
|
||||
CHANGELOG = [
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
{
|
||||
"id": "body_overview",
|
||||
"title": "Körper (Chart)",
|
||||
"description": "Gewicht & Kennzahlen",
|
||||
"description": "Gewicht & Kennzahlen (optional: config chart_days 7–90)",
|
||||
},
|
||||
{
|
||||
"id": "activity_overview",
|
||||
|
|
|
|||
|
|
@ -16,11 +16,14 @@ import { api } from '../../utils/api'
|
|||
import { useProfile } from '../../context/ProfileContext'
|
||||
import { getInterpretation } from '../../utils/interpret'
|
||||
import { rollingAvg, fmtDate } from '../../pilot/pilotChartUtils'
|
||||
import {
|
||||
BODY_CHART_DAYS_DEFAULT,
|
||||
normalizeBodyChartDays,
|
||||
} from '../../widgetSystem/bodyChartDays'
|
||||
import PilotRuleCard from './PilotRuleCard'
|
||||
|
||||
const WINDOW_DAYS = 30
|
||||
|
||||
export default function PilotBodySection({ refreshTick = 0 }) {
|
||||
export default function PilotBodySection({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) {
|
||||
const windowDays = normalizeBodyChartDays(chartDays)
|
||||
const { activeProfile } = useProfile()
|
||||
const [weights, setWeights] = useState([])
|
||||
const [calipers, setCalipers] = useState([])
|
||||
|
|
@ -31,10 +34,11 @@ export default function PilotBodySection({ refreshTick = 0 }) {
|
|||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const fetchDays = Math.max(120, windowDays + 60)
|
||||
const [w, ca, ci] = await Promise.all([
|
||||
api.listWeight(120),
|
||||
api.listCaliper(30),
|
||||
api.listCirc(30),
|
||||
api.listWeight(fetchDays),
|
||||
api.listCaliper(Math.max(30, windowDays)),
|
||||
api.listCirc(Math.max(30, windowDays)),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setWeights(Array.isArray(w) ? w : [])
|
||||
|
|
@ -54,11 +58,9 @@ export default function PilotBodySection({ refreshTick = 0 }) {
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [refreshTick])
|
||||
}, [refreshTick, windowDays])
|
||||
|
||||
const sex = activeProfile?.sex || 'm'
|
||||
const height = activeProfile?.height || 178
|
||||
const cutoff = dayjs().subtract(WINDOW_DAYS, 'day').format('YYYY-MM-DD')
|
||||
const cutoff = dayjs().subtract(windowDays, 'day').format('YYYY-MM-DD')
|
||||
|
||||
const filtW = [...(weights || [])]
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
|
@ -111,7 +113,7 @@ export default function PilotBodySection({ refreshTick = 0 }) {
|
|||
>
|
||||
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0, color: 'var(--text1)' }}>Bereich Körper</h2>
|
||||
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '6px 0 0', lineHeight: 1.5 }}>
|
||||
Fokus letzte {WINDOW_DAYS} Tage · Gewicht mit Ø 7 / Ø 14 Tage wie im Verlauf
|
||||
Fokus letzte {windowDays} Tage · Gewicht mit Ø 7 / Ø 14 Tage wie im Verlauf
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -128,7 +130,7 @@ export default function PilotBodySection({ refreshTick = 0 }) {
|
|||
<div className="card section-gap">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
|
||||
Gewicht · {filtW.length} Messungen ({WINDOW_DAYS}T)
|
||||
Gewicht · {filtW.length} Messungen ({windowDays}T)
|
||||
</div>
|
||||
<Link to="/history" state={{ tab: 'body' }} className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px', textDecoration: 'none' }}>
|
||||
Verlauf Körper <ChevronRight size={10} />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ import { Link } from 'react-router-dom'
|
|||
import { api, formatFastApiDetail } from '../utils/api'
|
||||
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||
import { moveWidget, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||
import {
|
||||
BODY_CHART_DAYS_DEFAULT,
|
||||
BODY_CHART_DAYS_MAX,
|
||||
BODY_CHART_DAYS_MIN,
|
||||
normalizeBodyChartDays,
|
||||
} from '../widgetSystem/bodyChartDays'
|
||||
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||
|
||||
function catalogMetaById(catalog) {
|
||||
if (!catalog?.widgets?.length) return {}
|
||||
|
|
@ -31,7 +37,7 @@ export default function DashboardLabPage() {
|
|||
const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
|
||||
setCatalog(cat)
|
||||
setBundle(b)
|
||||
setLayout(b.layout)
|
||||
setLayout(normalizeLayoutForEditor(b.layout))
|
||||
} catch (e) {
|
||||
setErr(formatFastApiDetail(null, e.message))
|
||||
}
|
||||
|
|
@ -64,7 +70,7 @@ export default function DashboardLabPage() {
|
|||
setErr(null)
|
||||
try {
|
||||
const r = await api.resetAppDashboardLayout()
|
||||
setLayout(r.layout)
|
||||
setLayout(normalizeLayoutForEditor(r.layout))
|
||||
setMsg('Auf Standard zurückgesetzt.')
|
||||
await load()
|
||||
} catch (e) {
|
||||
|
|
@ -76,7 +82,7 @@ export default function DashboardLabPage() {
|
|||
|
||||
const applyDefaultLocal = () => {
|
||||
if (bundle?.default_layout) {
|
||||
setLayout(structuredClone(bundle.default_layout))
|
||||
setLayout(normalizeLayoutForEditor(structuredClone(bundle.default_layout)))
|
||||
setMsg('Standard geladen (noch nicht gespeichert).')
|
||||
}
|
||||
}
|
||||
|
|
@ -115,8 +121,9 @@ export default function DashboardLabPage() {
|
|||
App-Bereich: Dashboard-Lab
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||
Widget-System (Iteration 1): Katalog vom Server, Registry im Frontend, Renderer für alle
|
||||
Pilot-Module. Layout wird pro Profil persistiert — getrennt vom Produktiv-Dashboard. Vergleich:{' '}
|
||||
Widget-System: Katalog, Registry, Renderer; optional pro Widget <code>config</code> (z. B.{' '}
|
||||
<strong>Körper-Chart</strong> 7–90 Tage). Layout pro Profil in der DB — getrennt vom Produktiv-Dashboard.
|
||||
Vergleich:{' '}
|
||||
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
||||
Pilot-Übersicht (festes Standard-Layout)
|
||||
</Link>
|
||||
|
|
@ -146,46 +153,94 @@ export default function DashboardLabPage() {
|
|||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
||||
{layout.widgets.map((w, i) => {
|
||||
const label = metaById[w.id]?.title || w.id
|
||||
const chartDaysVal =
|
||||
w.config?.chart_days != null ? normalizeBodyChartDays(w.config.chart_days) : BODY_CHART_DAYS_DEFAULT
|
||||
return (
|
||||
<li
|
||||
key={w.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 140px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={w.enabled}
|
||||
onChange={() => setLayout((L) => toggleWidget(L, i))}
|
||||
/>
|
||||
<span style={{ fontSize: 14 }}>{label}</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '6px 10px' }}
|
||||
aria-label="Nach oben"
|
||||
onClick={() => setLayout((L) => moveWidget(L, i, -1))}
|
||||
>
|
||||
<ChevronUp size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '6px 10px' }}
|
||||
aria-label="Nach unten"
|
||||
onClick={() => setLayout((L) => moveWidget(L, i, 1))}
|
||||
>
|
||||
<ChevronDown size={18} />
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 140px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={w.enabled}
|
||||
onChange={() => setLayout((L) => toggleWidget(L, i))}
|
||||
/>
|
||||
<span style={{ fontSize: 14 }}>{label}</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '6px 10px' }}
|
||||
aria-label="Nach oben"
|
||||
onClick={() => setLayout((L) => moveWidget(L, i, -1))}
|
||||
>
|
||||
<ChevronUp size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '6px 10px' }}
|
||||
aria-label="Nach unten"
|
||||
onClick={() => setLayout((L) => moveWidget(L, i, 1))}
|
||||
>
|
||||
<ChevronDown size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{w.id === 'body_overview' && (
|
||||
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
||||
Körper-Chart Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ maxWidth: 120 }}
|
||||
min={BODY_CHART_DAYS_MIN}
|
||||
max={BODY_CHART_DAYS_MAX}
|
||||
value={chartDaysVal}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
setLayout((L) => ({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) =>
|
||||
j !== i
|
||||
? x
|
||||
: {
|
||||
...x,
|
||||
config: {
|
||||
...x.config,
|
||||
chart_days: Number.isFinite(v) ? v : BODY_CHART_DAYS_DEFAULT,
|
||||
},
|
||||
}
|
||||
),
|
||||
}))
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const clamped = normalizeBodyChartDays(e.target.value)
|
||||
setLayout((L) => ({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) =>
|
||||
j !== i ? x : { ...x, config: { ...x.config, chart_days: clamped } }
|
||||
),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
10
frontend/src/widgetSystem/bodyChartDays.js
Normal file
10
frontend/src/widgetSystem/bodyChartDays.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** Körper-Chart: gültiger Bereich (sync mit backend dashboard_widget_config body_overview). */
|
||||
export const BODY_CHART_DAYS_MIN = 7
|
||||
export const BODY_CHART_DAYS_MAX = 90
|
||||
export const BODY_CHART_DAYS_DEFAULT = 30
|
||||
|
||||
export function normalizeBodyChartDays(raw) {
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n)) return BODY_CHART_DAYS_DEFAULT
|
||||
return Math.min(BODY_CHART_DAYS_MAX, Math.max(BODY_CHART_DAYS_MIN, Math.round(n)))
|
||||
}
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
/** @typedef {{ refreshTick: number, requestRefresh: () => void }} WidgetRenderContext */
|
||||
/**
|
||||
* @typedef {object} LayoutWidgetEntry
|
||||
* @property {string} id
|
||||
* @property {boolean} enabled
|
||||
* @property {Record<string, unknown>} [config]
|
||||
*/
|
||||
/**
|
||||
* @typedef {{ refreshTick: number, requestRefresh: () => void, layoutEntry: LayoutWidgetEntry }} WidgetRenderContext
|
||||
*/
|
||||
|
||||
const registry = new Map()
|
||||
|
||||
|
|
@ -49,12 +57,21 @@ export function renderRegisteredWidget(id, ctx) {
|
|||
|
||||
/**
|
||||
* Rendert alle aktivierten Widgets in Layout-Reihenfolge.
|
||||
* @param {{ version: number, widgets: Array<{ id: string, enabled: boolean }> }} layout
|
||||
* @param {WidgetRenderContext} ctx
|
||||
* @param {{ version: number, widgets: Array<LayoutWidgetEntry> }} layout
|
||||
* @param {{ refreshTick: number, requestRefresh: () => void }} base
|
||||
*/
|
||||
export function WidgetRenderer({ layout, refreshTick, requestRefresh }) {
|
||||
if (!layout?.widgets?.length) return null
|
||||
const ctx = { refreshTick, requestRefresh }
|
||||
const enabled = layout.widgets.filter((w) => w.enabled)
|
||||
return <>{enabled.map((w) => renderRegisteredWidget(w.id, ctx))}</>
|
||||
return (
|
||||
<>
|
||||
{enabled.map((w) =>
|
||||
renderRegisteredWidget(w.id, {
|
||||
refreshTick,
|
||||
requestRefresh,
|
||||
layoutEntry: w,
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
export function normalizeLayoutForEditor(layout) {
|
||||
if (!layout?.widgets) return layout
|
||||
return {
|
||||
...layout,
|
||||
widgets: layout.widgets.map((w) => ({
|
||||
...w,
|
||||
config: w.config && typeof w.config === 'object' ? { ...w.config } : {},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function moveWidget(layout, index, delta) {
|
||||
const next = [...layout.widgets]
|
||||
const j = index + delta
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import PilotQuickCapture from '../components/pilot/PilotQuickCapture'
|
|||
import PilotKpiBoard from '../components/pilot/PilotKpiBoard'
|
||||
import PilotBodySection from '../components/pilot/PilotBodySection'
|
||||
import PilotActivitySection from '../components/pilot/PilotActivitySection'
|
||||
import { normalizeBodyChartDays } from './bodyChartDays'
|
||||
import { registerDashboardWidget } from './dashboardWidgetRegistry'
|
||||
|
||||
let _registered = false
|
||||
|
|
@ -32,7 +33,10 @@ export function ensurePilotLabWidgetsRegistered() {
|
|||
registerDashboardWidget({
|
||||
id: 'body_overview',
|
||||
Component: PilotBodySection,
|
||||
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||
mapProps: (ctx) => ({
|
||||
refreshTick: ctx.refreshTick,
|
||||
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
|
||||
}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'activity_overview',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user