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:
Developer
2026-07-06 12:11:47 +00:00
parent c36262d7b6
commit 1e636fdbe2
9 changed files with 294 additions and 70 deletions
@@ -269,6 +269,19 @@ def delete_reference(
return {"status": "deleted"}
@router.put("/references/{reference_id}")
def update_reference(
reference_id: str,
sort_order: int,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Update a widget reference's sort_order (per-dashboard reordering)."""
try:
return store.update_widget_reference(reference_id, sort_order)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/references/{reference_id}/detach")
def detach_reference(
reference_id: str,
@@ -1570,6 +1570,30 @@ class SettingsStore:
with self.connect() as conn:
conn.execute("DELETE FROM widget_references WHERE id = ?", (reference_id,))
def update_widget_reference(self, reference_id: str, sort_order: int) -> dict[str, Any]:
"""Update only the sort_order on a widget reference (per-dashboard reordering)."""
self.init_schema()
with self.connect() as conn:
row = conn.execute(
"SELECT * FROM widget_references WHERE id = ?",
(reference_id,),
).fetchone()
if not row:
raise ValueError(f"Reference {reference_id} not found")
conn.execute(
"UPDATE widget_references SET sort_order = ? WHERE id = ?",
(sort_order, reference_id),
)
widget = self.get_widget(row["widget_id"])
return {
"id": row["id"],
"dashboard_scope": row["dashboard_scope"],
"widget_id": row["widget_id"],
"sort_order": sort_order,
"created_at": int(row["created_at"]),
"widget": widget,
}
def detach_widget_reference(self, reference_id: str, dashboard_scope: str) -> dict[str, Any]:
"""Clone the referenced widget into a new standalone instance owned by the scope."""
self.init_schema()
@@ -1583,10 +1607,11 @@ class SettingsStore:
source = self.get_widget(row["widget_id"])
if not source:
raise ValueError(f"Source widget {row['widget_id']} not found")
# Clone: new widget with service_id=NULL (dashboard scope), same config/kind/title.
# Clone: copy the widget verbatim including service_id (so service-bound
# widgets keep working), only the id/created_at change.
cloned = self.upsert_widget(
{
"service_id": None,
"service_id": source.get("service_id"),
"widget_kind": source["widget_kind"],
"title": source["title"],
"config": source["config"],
+96 -26
View File
@@ -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