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:
@@ -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"],
|
||||
|
||||
@@ -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