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