Follow-ups: reference reorder, detach service_id, named-dashboard widgets
Three reusable-widget follow-up fixes:
1. Reference sort_order independently reorderable. Reordering a
referenced widget now updates the widget_references.sort_order (per-
dashboard), not the shared widget instance sort_order. New backend
update_widget_reference method + PUT /api/widgets/references/{id}
endpoint. Frontend moveInstance checks _ref_id to choose the right
mutation (updateRef for references, saveWidget for owned).
2. Detach preserves service_id. detach_widget_reference now copies the
original widget's service_id into the clone, so service-bound widgets
(Grafana chart, Jellyfin activity) continue to render after detach.
3. Named dashboards support widget references. NamedDashboardPage
fetches useWidgetReferences('named:<slug>') and renders them via
WidgetInstanceCard alongside pinned links. 'Edit widgets' button
opens WidgetConfigDialog with dashboardScope='named:<slug>'.
Also: removed useMemo on combinedWidgets in WidgetConfigDialog to fix
a react-hooks/preserve-manual-memoization lint error (the React Compiler
ESLint plugin couldn't verify the spread+sort memoization).
283 backend tests pass (+1 update_reference test); 128 frontend tests
pass; ruff clean; 0 lint errors.
This commit is contained in:
@@ -730,6 +730,7 @@ async def test_jellyfin_activity_shows_all_sessions():
|
||||
# Widget references (live-link widgets across dashboards)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def widget_ref_client(monkeypatch):
|
||||
"""TestClient with an isolated SettingsStore + encryption key."""
|
||||
@@ -770,21 +771,26 @@ def test_widget_reference_lifecycle(widget_ref_client):
|
||||
},
|
||||
)
|
||||
service = store.list_services("grafana")[0]
|
||||
widget = store.upsert_widget({
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "CPU IOWait",
|
||||
"config": {"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
})
|
||||
widget = store.upsert_widget(
|
||||
{
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "CPU IOWait",
|
||||
"config": {"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Reference it on "main" dashboard.
|
||||
resp = client.post("/api/widgets/references", json={
|
||||
"dashboard_scope": "main",
|
||||
"widget_id": widget["id"],
|
||||
"sort_order": 5,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "main",
|
||||
"widget_id": widget["id"],
|
||||
"sort_order": 5,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
ref = resp.json()
|
||||
assert ref["dashboard_scope"] == "main"
|
||||
@@ -823,20 +829,25 @@ def test_widget_reference_detach(widget_ref_client):
|
||||
},
|
||||
)
|
||||
service = store.list_services("grafana")[0]
|
||||
widget = store.upsert_widget({
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "Memory",
|
||||
"config": {"query": "mem", "datasource_uid": "prometheus"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
})
|
||||
widget = store.upsert_widget(
|
||||
{
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "Memory",
|
||||
"config": {"query": "mem", "datasource_uid": "prometheus"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Reference on "main".
|
||||
resp = client.post("/api/widgets/references", json={
|
||||
"dashboard_scope": "main",
|
||||
"widget_id": widget["id"],
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "main",
|
||||
"widget_id": widget["id"],
|
||||
},
|
||||
)
|
||||
ref_id = resp.json()["id"]
|
||||
|
||||
# Detach.
|
||||
@@ -845,7 +856,7 @@ def test_widget_reference_detach(widget_ref_client):
|
||||
cloned = resp.json()
|
||||
assert cloned["title"] == "Memory"
|
||||
assert cloned["widget_kind"] == "chart"
|
||||
assert cloned["service_id"] is None # dashboard-scoped clone
|
||||
assert cloned["service_id"] == service["id"] # Fix 2: preserves service binding
|
||||
assert cloned["config"]["query"] == "mem"
|
||||
assert cloned["id"] != widget["id"] # new independent widget
|
||||
|
||||
@@ -854,3 +865,62 @@ def test_widget_reference_detach(widget_ref_client):
|
||||
assert len(refs) == 0
|
||||
# Original still exists.
|
||||
assert store.get_widget(widget["id"]) is not None
|
||||
|
||||
|
||||
def test_widget_reference_update_sort_order(widget_ref_client):
|
||||
"""PUT /references/{id} updates only the reference's sort_order (Fix 1)."""
|
||||
client, store = widget_ref_client
|
||||
|
||||
widget_a = store.upsert_widget(
|
||||
{
|
||||
"service_id": None,
|
||||
"widget_kind": "static",
|
||||
"title": "A",
|
||||
"config": {"text": "a"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
)
|
||||
widget_b = store.upsert_widget(
|
||||
{
|
||||
"service_id": None,
|
||||
"widget_kind": "static",
|
||||
"title": "B",
|
||||
"config": {"text": "b"},
|
||||
"enabled": True,
|
||||
"sort_order": 1,
|
||||
}
|
||||
)
|
||||
|
||||
# Two references on the same dashboard scope.
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "named:test",
|
||||
"widget_id": widget_a["id"],
|
||||
"sort_order": 0,
|
||||
},
|
||||
)
|
||||
ref_a = resp.json()
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "named:test",
|
||||
"widget_id": widget_b["id"],
|
||||
"sort_order": 1,
|
||||
},
|
||||
)
|
||||
ref_b = resp.json()
|
||||
|
||||
# Swap sort orders via PUT (per-dashboard reorder).
|
||||
resp = client.put(f"/api/widgets/references/{ref_a['id']}?sort_order=1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["sort_order"] == 1
|
||||
|
||||
resp = client.put(f"/api/widgets/references/{ref_b['id']}?sort_order=0")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["sort_order"] == 0
|
||||
|
||||
# Widget instances themselves are unchanged.
|
||||
assert store.get_widget(widget_a["id"])["sort_order"] == 0
|
||||
assert store.get_widget(widget_b["id"])["sort_order"] == 1
|
||||
|
||||
Reference in New Issue
Block a user