Reusable widgets: reference widgets across dashboards + detach to clone
Widgets configured on one dashboard (e.g., a Grafana service's Overview)
can now be live-referenced on other dashboards. Editing the widget config
updates it everywhere it's referenced. References can be detached into
independent clones.
Backend: new widget_references table (dashboard_scope, widget_id,
sort_order) with ON DELETE CASCADE. CRUD methods + 4 endpoints:
GET/POST /api/widgets/references, DELETE /api/widgets/references/{id},
POST /api/widgets/references/{id}/detach (clones the widget into a
standalone instance, then removes the reference).
Frontend: WidgetConfigDialog gains a dashboardScope prop. When set
(the main Dashboard passes 'main'), the dialog shows:
- Owned + referenced widgets in a combined list, with a link badge on
references.
- 'Add existing widget' picker: searchable list of ALL widget instances
not already on this dashboard. Click to create a reference.
- Detach button on references: clones the widget (service_id=NULL) and
removes the reference.
- Delete on a reference removes the REFERENCE (not the original widget).
Dashboard renders referenced widgets alongside owned widgets.
Detaching a service-bound widget clones it with service_id=NULL — the
clone may need re-binding to a service to render correctly. Named
dashboards don't pass dashboardScope yet (pinned-links-only); when they
gain widget support, the backend already handles any scope string.
282 backend tests pass (+2 reference lifecycle); 127 frontend tests
pass; ruff/eslint/tsc/vite all green.
This commit is contained in:
@@ -724,3 +724,133 @@ async def test_jellyfin_activity_shows_all_sessions():
|
||||
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
|
||||
result = await adapter.fetch(service, "activity", {})
|
||||
assert len(result["sessions"]) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget references (live-link widgets across dashboards)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def widget_ref_client(monkeypatch):
|
||||
"""TestClient with an isolated SettingsStore + encryption key."""
|
||||
monkeypatch.setenv(
|
||||
"MANAGE_ENCRYPTION_KEY",
|
||||
Fernet.generate_key().decode(),
|
||||
)
|
||||
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
|
||||
|
||||
reset_encryption_key_cache()
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
store = SettingsStore(str(Path(tempfile.mkdtemp()) / "test.db"))
|
||||
store.ensure_defaults()
|
||||
|
||||
def get_store_override():
|
||||
return store
|
||||
|
||||
app.dependency_overrides[get_settings_store] = get_store_override
|
||||
client = TestClient(app)
|
||||
yield client, store
|
||||
app.dependency_overrides.pop(get_settings_store, None)
|
||||
|
||||
|
||||
def test_widget_reference_lifecycle(widget_ref_client):
|
||||
"""Create a widget, reference it on 'main', verify it appears, delete reference."""
|
||||
client, store = widget_ref_client
|
||||
|
||||
# Create a service-bound widget (simulating one on a Grafana Overview).
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "grafana",
|
||||
"name": "Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"secrets": {"api_key": "tok"},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
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,
|
||||
})
|
||||
|
||||
# Reference it on "main" dashboard.
|
||||
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"
|
||||
assert ref["widget_id"] == widget["id"]
|
||||
ref_id = ref["id"]
|
||||
|
||||
# List references for "main" — should include our widget.
|
||||
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
|
||||
assert resp.status_code == 200
|
||||
refs = resp.json()
|
||||
assert len(refs) == 1
|
||||
assert refs[0]["widget"]["title"] == "CPU IOWait"
|
||||
|
||||
# Delete the reference.
|
||||
resp = client.delete(f"/api/widgets/references/{ref_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "deleted"
|
||||
|
||||
# Reference is gone, original widget still exists.
|
||||
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
|
||||
assert len(resp.json()) == 0
|
||||
assert store.get_widget(widget["id"]) is not None
|
||||
|
||||
|
||||
def test_widget_reference_detach(widget_ref_client):
|
||||
"""Detach clones the widget into a standalone instance and removes the reference."""
|
||||
client, store = widget_ref_client
|
||||
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "grafana",
|
||||
"name": "Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"secrets": {"api_key": "tok"},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
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,
|
||||
})
|
||||
|
||||
# Reference on "main".
|
||||
resp = client.post("/api/widgets/references", json={
|
||||
"dashboard_scope": "main",
|
||||
"widget_id": widget["id"],
|
||||
})
|
||||
ref_id = resp.json()["id"]
|
||||
|
||||
# Detach.
|
||||
resp = client.post(f"/api/widgets/references/{ref_id}/detach")
|
||||
assert resp.status_code == 200
|
||||
cloned = resp.json()
|
||||
assert cloned["title"] == "Memory"
|
||||
assert cloned["widget_kind"] == "chart"
|
||||
assert cloned["service_id"] is None # dashboard-scoped clone
|
||||
assert cloned["config"]["query"] == "mem"
|
||||
assert cloned["id"] != widget["id"] # new independent widget
|
||||
|
||||
# Reference is gone.
|
||||
refs = client.get("/api/widgets/references", params={"dashboard_scope": "main"}).json()
|
||||
assert len(refs) == 0
|
||||
# Original still exists.
|
||||
assert store.get_widget(widget["id"]) is not None
|
||||
|
||||
Reference in New Issue
Block a user