feat: Extend widget configuration for activity overview and improve validation
- Added support for the "activity_overview" widget in the dashboard configuration, allowing for chart_days validation. - Refactored validation logic to streamline error handling for both "body_overview" and "activity_overview" widgets. - Updated the widget catalog description to reflect the new configuration options. - Enhanced the DashboardLabPage to manage chart_days input for both widgets, improving user experience. - Bumped app_dashboard version to 1.3.0 to reflect these enhancements.
This commit is contained in:
parent
4493b140bd
commit
b617212145
|
|
@ -11,7 +11,7 @@ from typing import Any
|
||||||
|
|
||||||
MAX_WIDGET_CONFIG_JSON_BYTES = 1024
|
MAX_WIDGET_CONFIG_JSON_BYTES = 1024
|
||||||
|
|
||||||
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview"})
|
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview", "activity_overview"})
|
||||||
|
|
||||||
|
|
||||||
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
||||||
|
|
@ -32,33 +32,35 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||||
|
|
||||||
if widget_id == "body_overview":
|
if widget_id == "body_overview":
|
||||||
return _validate_body_overview_config(raw)
|
return _validate_chart_days_only(raw, label="body_overview")
|
||||||
|
if widget_id == "activity_overview":
|
||||||
|
return _validate_chart_days_only(raw, label="activity_overview")
|
||||||
|
|
||||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||||
|
|
||||||
|
|
||||||
def _validate_body_overview_config(raw: dict[str, Any]) -> dict[str, Any]:
|
def _parse_chart_days(v: Any, label: str) -> int:
|
||||||
|
if isinstance(v, bool):
|
||||||
|
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
||||||
|
if isinstance(v, float):
|
||||||
|
if not math.isfinite(v):
|
||||||
|
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
||||||
|
if abs(v - round(v)) > 1e-9:
|
||||||
|
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
||||||
|
return int(round(v))
|
||||||
|
if isinstance(v, int):
|
||||||
|
return v
|
||||||
|
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
|
||||||
allowed = frozenset({"chart_days"})
|
allowed = frozenset({"chart_days"})
|
||||||
unknown = set(raw) - allowed
|
unknown = set(raw) - allowed
|
||||||
if unknown:
|
if unknown:
|
||||||
raise ValueError(f"body_overview: unbekannte config-Felder: {sorted(unknown)}")
|
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
||||||
out: dict[str, Any] = {}
|
|
||||||
if "chart_days" not in raw:
|
if "chart_days" not in raw:
|
||||||
return out
|
return {}
|
||||||
v = raw["chart_days"]
|
v = _parse_chart_days(raw["chart_days"], label)
|
||||||
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:
|
if v < 7 or v > 90:
|
||||||
raise ValueError("body_overview: chart_days muss zwischen 7 und 90 liegen")
|
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
||||||
out["chart_days"] = v
|
return {"chart_days": v}
|
||||||
return out
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,17 @@ def test_body_unknown_key():
|
||||||
validate_widget_entry_config("body_overview", {"chart_days": 30, "extra": 1})
|
validate_widget_entry_config("body_overview", {"chart_days": 30, "extra": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_chart_days():
|
||||||
|
assert validate_widget_entry_config("activity_overview", {"chart_days": 14}) == {"chart_days": 14}
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_widget_entry_config("activity_overview", {"chart_days": 5})
|
||||||
|
|
||||||
|
|
||||||
|
def test_kpi_config_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_widget_entry_config("kpi_board", {"chart_days": 30})
|
||||||
|
|
||||||
|
|
||||||
def test_layout_payload_with_chart_days_roundtrip():
|
def test_layout_payload_with_chart_days_roundtrip():
|
||||||
p = DashboardLayoutPayload.model_validate(
|
p = DashboardLayoutPayload.model_validate(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
||||||
"importdata": "1.0.0",
|
"importdata": "1.0.0",
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
||||||
"app_dashboard": "1.2.0", # Widget-Config (body_overview.chart_days) + Validierung
|
"app_dashboard": "1.3.0", # activity_overview.chart_days + WidgetErrorBoundary
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
{
|
{
|
||||||
"id": "activity_overview",
|
"id": "activity_overview",
|
||||||
"title": "Aktivität",
|
"title": "Aktivität",
|
||||||
"description": "Training & Konsistenz",
|
"description": "Training & Konsistenz (optional: config chart_days 7–90)",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ import dayjs from 'dayjs'
|
||||||
import { api } from '../../utils/api'
|
import { api } from '../../utils/api'
|
||||||
import { useProfile } from '../../context/ProfileContext'
|
import { useProfile } from '../../context/ProfileContext'
|
||||||
import TrainingTypeDistribution from '../TrainingTypeDistribution'
|
import TrainingTypeDistribution from '../TrainingTypeDistribution'
|
||||||
|
import {
|
||||||
|
BODY_CHART_DAYS_DEFAULT,
|
||||||
|
normalizeBodyChartDays,
|
||||||
|
} from '../../widgetSystem/bodyChartDays'
|
||||||
import PilotRuleCard from './PilotRuleCard'
|
import PilotRuleCard from './PilotRuleCard'
|
||||||
|
|
||||||
const PERIOD = 30
|
export default function PilotActivitySection({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) {
|
||||||
|
const periodDays = normalizeBodyChartDays(chartDays)
|
||||||
export default function PilotActivitySection({ refreshTick = 0 }) {
|
|
||||||
const { activeProfile } = useProfile()
|
const { activeProfile } = useProfile()
|
||||||
const globalQualityLevel = activeProfile?.quality_filter_level
|
const globalQualityLevel = activeProfile?.quality_filter_level
|
||||||
const [activities, setActivities] = useState([])
|
const [activities, setActivities] = useState([])
|
||||||
|
|
@ -18,7 +21,8 @@ export default function PilotActivitySection({ refreshTick = 0 }) {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const a = await api.listActivity(120)
|
const fetchDays = Math.max(120, periodDays + 60)
|
||||||
|
const a = await api.listActivity(fetchDays)
|
||||||
if (!cancelled) setActivities(Array.isArray(a) ? a : [])
|
if (!cancelled) setActivities(Array.isArray(a) ? a : [])
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setActivities([])
|
if (!cancelled) setActivities([])
|
||||||
|
|
@ -29,15 +33,15 @@ export default function PilotActivitySection({ refreshTick = 0 }) {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [refreshTick, globalQualityLevel])
|
}, [refreshTick, globalQualityLevel, periodDays])
|
||||||
|
|
||||||
const cutoff = dayjs().subtract(PERIOD, 'day').format('YYYY-MM-DD')
|
const cutoff = dayjs().subtract(periodDays, 'day').format('YYYY-MM-DD')
|
||||||
const filtA = (activities || []).filter((d) => d.date >= cutoff)
|
const filtA = (activities || []).filter((d) => d.date >= cutoff)
|
||||||
|
|
||||||
const daysWithAct = new Set(filtA.map((a) => a.date)).size
|
const daysWithAct = new Set(filtA.map((a) => a.date)).size
|
||||||
const totalDays =
|
const totalDays =
|
||||||
filtA.length > 0
|
filtA.length > 0
|
||||||
? Math.min(PERIOD, dayjs().diff(dayjs(filtA[filtA.length - 1]?.date), 'day') + 1)
|
? Math.min(periodDays, dayjs().diff(dayjs(filtA[filtA.length - 1]?.date), 'day') + 1)
|
||||||
: 0
|
: 0
|
||||||
const consistency = totalDays > 0 ? Math.round((daysWithAct / totalDays) * 100) : 0
|
const consistency = totalDays > 0 ? Math.round((daysWithAct / totalDays) * 100) : 0
|
||||||
|
|
||||||
|
|
@ -46,7 +50,7 @@ export default function PilotActivitySection({ refreshTick = 0 }) {
|
||||||
status: consistency >= 70 ? 'good' : consistency >= 40 ? 'warn' : 'bad',
|
status: consistency >= 70 ? 'good' : consistency >= 40 ? 'warn' : 'bad',
|
||||||
icon: '📅',
|
icon: '📅',
|
||||||
category: 'Konsistenz',
|
category: 'Konsistenz',
|
||||||
title: `${consistency}% aktive Tage (${daysWithAct}/${Math.min(PERIOD, totalDays || PERIOD)} Tage)`,
|
title: `${consistency}% aktive Tage (${daysWithAct}/${Math.min(periodDays, totalDays || periodDays)} Tage)`,
|
||||||
detail:
|
detail:
|
||||||
consistency >= 70
|
consistency >= 70
|
||||||
? 'Ausgezeichnete Regelmäßigkeit.'
|
? 'Ausgezeichnete Regelmäßigkeit.'
|
||||||
|
|
@ -77,7 +81,7 @@ export default function PilotActivitySection({ refreshTick = 0 }) {
|
||||||
>
|
>
|
||||||
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0, color: 'var(--text1)' }}>Bereich Aktivität</h2>
|
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0, color: 'var(--text1)' }}>Bereich Aktivität</h2>
|
||||||
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '6px 0 0', lineHeight: 1.5 }}>
|
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '6px 0 0', lineHeight: 1.5 }}>
|
||||||
Trainingstyp-Verteilung {PERIOD} Tage · Bewertung Konsistenz wie im Verlauf
|
Trainingstyp-Verteilung {periodDays} Tage · Bewertung Konsistenz wie im Verlauf
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -102,7 +106,7 @@ export default function PilotActivitySection({ refreshTick = 0 }) {
|
||||||
|
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Trainingstyp-Verteilung</div>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Trainingstyp-Verteilung</div>
|
||||||
<TrainingTypeDistribution days={PERIOD} />
|
<TrainingTypeDistribution days={periodDays} />
|
||||||
<div style={{ marginTop: 8, textAlign: 'right' }}>
|
<div style={{ marginTop: 8, textAlign: 'right' }}>
|
||||||
<Link to="/history" state={{ tab: 'activity' }} style={{ fontSize: 12, color: 'var(--accent)' }}>
|
<Link to="/history" state={{ tab: 'activity' }} style={{ fontSize: 12, color: 'var(--accent)' }}>
|
||||||
Vollständiger Verlauf Aktivität →
|
Vollständiger Verlauf Aktivität →
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import {
|
||||||
} from '../widgetSystem/bodyChartDays'
|
} from '../widgetSystem/bodyChartDays'
|
||||||
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||||
|
|
||||||
|
/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */
|
||||||
|
const CHART_DAYS_WIDGET_IDS = new Set(['body_overview', 'activity_overview'])
|
||||||
|
|
||||||
function catalogMetaById(catalog) {
|
function catalogMetaById(catalog) {
|
||||||
if (!catalog?.widgets?.length) return {}
|
if (!catalog?.widgets?.length) return {}
|
||||||
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
|
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
|
||||||
|
|
@ -28,17 +31,19 @@ export default function DashboardLabPage() {
|
||||||
const [err, setErr] = useState(null)
|
const [err, setErr] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [msg, setMsg] = useState(null)
|
const [msg, setMsg] = useState(null)
|
||||||
/** Während der Fokus im Körper-Chart-Feld: Rohstring, damit Tippen (z. B. „14“) nicht sofort geclamped wird */
|
/** Pro Widget-ID: Rohstring während der Eingabe (Tippen ohne sofortiges Clampen) */
|
||||||
const [bodyChartDaysDraft, setBodyChartDaysDraft] = useState(null)
|
const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({})
|
||||||
|
|
||||||
const metaById = catalogMetaById(catalog)
|
const metaById = catalogMetaById(catalog)
|
||||||
|
|
||||||
const commitBodyChartDraftToLayout = useCallback((draftStr, baseLayout) => {
|
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
|
||||||
const clamped = normalizeBodyChartDays(draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr)
|
const clamped = normalizeBodyChartDays(
|
||||||
|
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
...baseLayout,
|
...baseLayout,
|
||||||
widgets: baseLayout.widgets.map((x) =>
|
widgets: baseLayout.widgets.map((x) =>
|
||||||
x.id !== 'body_overview' ? x : { ...x, config: { ...x.config, chart_days: clamped } }
|
x.id !== widgetId ? x : { ...x, config: { ...x.config, chart_days: clamped } }
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -49,7 +54,7 @@ export default function DashboardLabPage() {
|
||||||
const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
|
const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
|
||||||
setCatalog(cat)
|
setCatalog(cat)
|
||||||
setBundle(b)
|
setBundle(b)
|
||||||
setBodyChartDaysDraft(null)
|
setChartDaysDraftByWidgetId({})
|
||||||
setLayout(normalizeLayoutForEditor(b.layout))
|
setLayout(normalizeLayoutForEditor(b.layout))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(formatFastApiDetail(null, e.message))
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
|
@ -63,10 +68,13 @@ export default function DashboardLabPage() {
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!layout) return
|
if (!layout) return
|
||||||
let toSave = layout
|
let toSave = layout
|
||||||
if (bodyChartDaysDraft !== null) {
|
const draftEntries = Object.entries(chartDaysDraftByWidgetId)
|
||||||
toSave = normalizeLayoutForEditor(commitBodyChartDraftToLayout(bodyChartDaysDraft, layout))
|
if (draftEntries.length) {
|
||||||
|
for (const [wid, val] of draftEntries) {
|
||||||
|
toSave = normalizeLayoutForEditor(commitChartDaysDraftToLayout(val, toSave, wid))
|
||||||
|
}
|
||||||
setLayout(toSave)
|
setLayout(toSave)
|
||||||
setBodyChartDaysDraft(null)
|
setChartDaysDraftByWidgetId({})
|
||||||
}
|
}
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setMsg(null)
|
setMsg(null)
|
||||||
|
|
@ -89,7 +97,7 @@ export default function DashboardLabPage() {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
try {
|
try {
|
||||||
const r = await api.resetAppDashboardLayout()
|
const r = await api.resetAppDashboardLayout()
|
||||||
setBodyChartDaysDraft(null)
|
setChartDaysDraftByWidgetId({})
|
||||||
setLayout(normalizeLayoutForEditor(r.layout))
|
setLayout(normalizeLayoutForEditor(r.layout))
|
||||||
setMsg('Auf Standard zurückgesetzt.')
|
setMsg('Auf Standard zurückgesetzt.')
|
||||||
await load()
|
await load()
|
||||||
|
|
@ -102,7 +110,7 @@ export default function DashboardLabPage() {
|
||||||
|
|
||||||
const applyDefaultLocal = () => {
|
const applyDefaultLocal = () => {
|
||||||
if (bundle?.default_layout) {
|
if (bundle?.default_layout) {
|
||||||
setBodyChartDaysDraft(null)
|
setChartDaysDraftByWidgetId({})
|
||||||
setLayout(normalizeLayoutForEditor(structuredClone(bundle.default_layout)))
|
setLayout(normalizeLayoutForEditor(structuredClone(bundle.default_layout)))
|
||||||
setMsg('Standard geladen (noch nicht gespeichert).')
|
setMsg('Standard geladen (noch nicht gespeichert).')
|
||||||
}
|
}
|
||||||
|
|
@ -143,7 +151,8 @@ export default function DashboardLabPage() {
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||||
Widget-System: Katalog, Registry, Renderer; optional pro Widget <code>config</code> (z. B.{' '}
|
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.
|
<strong>Körper</strong> und <strong>Aktivität</strong>: Zeitraum 7–90 Tage). Layout pro Profil in der DB —
|
||||||
|
getrennt vom Produktiv-Dashboard.
|
||||||
Vergleich:{' '}
|
Vergleich:{' '}
|
||||||
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
||||||
Pilot-Übersicht (festes Standard-Layout)
|
Pilot-Übersicht (festes Standard-Layout)
|
||||||
|
|
@ -221,10 +230,11 @@ export default function DashboardLabPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{w.id === 'body_overview' && (
|
{CHART_DAYS_WIDGET_IDS.has(w.id) && (
|
||||||
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||||
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
||||||
Körper-Chart Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
{w.id === 'body_overview' ? 'Körper-Chart' : 'Aktivität (Verteilung & Konsistenz)'} — Zeitraum
|
||||||
|
(Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -232,21 +242,38 @@ export default function DashboardLabPage() {
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ maxWidth: 120 }}
|
style={{ maxWidth: 120 }}
|
||||||
aria-label="Körper-Chart Zeitraum in Tagen"
|
aria-label={
|
||||||
|
w.id === 'body_overview'
|
||||||
|
? 'Körper-Chart Zeitraum in Tagen'
|
||||||
|
: 'Aktivität Zeitraum in Tagen'
|
||||||
|
}
|
||||||
value={
|
value={
|
||||||
bodyChartDaysDraft !== null
|
chartDaysDraftByWidgetId[w.id] !== undefined
|
||||||
? bodyChartDaysDraft
|
? chartDaysDraftByWidgetId[w.id]
|
||||||
: String(chartDaysVal)
|
: String(chartDaysVal)
|
||||||
}
|
}
|
||||||
onFocus={() => setBodyChartDaysDraft(String(chartDaysVal))}
|
onFocus={() =>
|
||||||
onChange={(e) => setBodyChartDaysDraft(e.target.value)}
|
setChartDaysDraftByWidgetId((prev) => ({
|
||||||
onBlur={() => {
|
...prev,
|
||||||
|
[w.id]: String(chartDaysVal),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setChartDaysDraftByWidgetId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[w.id]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const raw = e.target.value
|
||||||
setLayout((L) =>
|
setLayout((L) =>
|
||||||
normalizeLayoutForEditor(
|
normalizeLayoutForEditor(commitChartDaysDraftToLayout(raw, L, w.id))
|
||||||
commitBodyChartDraftToLayout(bodyChartDaysDraft ?? String(chartDaysVal), L)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
setBodyChartDaysDraft(null)
|
setChartDaysDraftByWidgetId((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[w.id]
|
||||||
|
return next
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') e.currentTarget.blur()
|
if (e.key === 'Enter') e.currentTarget.blur()
|
||||||
|
|
|
||||||
51
frontend/src/widgetSystem/WidgetErrorBoundary.jsx
Normal file
51
frontend/src/widgetSystem/WidgetErrorBoundary.jsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Component } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verhindert, dass ein fehlerhaftes Dashboard-Widget die ganze Seite mitreißt.
|
||||||
|
*/
|
||||||
|
export default class WidgetErrorBoundary extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
const msg =
|
||||||
|
this.state.error && typeof this.state.error === 'object' && 'message' in this.state.error
|
||||||
|
? String(this.state.error.message)
|
||||||
|
: String(this.state.error)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card section-gap"
|
||||||
|
style={{ marginBottom: 16, borderColor: 'var(--danger, #D85A30)' }}
|
||||||
|
>
|
||||||
|
<div className="card-title" style={{ color: 'var(--danger, #D85A30)' }}>
|
||||||
|
Widget-Fehler
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '4px 0 8px' }}>
|
||||||
|
<code>{this.props.widgetId}</code>
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
overflow: 'auto',
|
||||||
|
margin: 0,
|
||||||
|
padding: 8,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
color: 'var(--text2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import WidgetErrorBoundary from './WidgetErrorBoundary'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {object} LayoutWidgetEntry
|
* @typedef {object} LayoutWidgetEntry
|
||||||
* @property {string} id
|
* @property {string} id
|
||||||
|
|
@ -52,7 +54,11 @@ export function renderRegisteredWidget(id, ctx) {
|
||||||
}
|
}
|
||||||
const { Component } = spec
|
const { Component } = spec
|
||||||
const props = spec.mapProps ? spec.mapProps(ctx) : {}
|
const props = spec.mapProps ? spec.mapProps(ctx) : {}
|
||||||
return <Component key={id} {...props} />
|
return (
|
||||||
|
<WidgetErrorBoundary key={id} widgetId={id}>
|
||||||
|
<Component {...props} />
|
||||||
|
</WidgetErrorBoundary>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,10 @@ export function ensurePilotLabWidgetsRegistered() {
|
||||||
registerDashboardWidget({
|
registerDashboardWidget({
|
||||||
id: 'activity_overview',
|
id: 'activity_overview',
|
||||||
Component: PilotActivitySection,
|
Component: PilotActivitySection,
|
||||||
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
mapProps: (ctx) => ({
|
||||||
|
refreshTick: ctx.refreshTick,
|
||||||
|
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user