From c36262d7b6af5c31e4bdc406fd08dc381f61e60c Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 6 Jul 2026 11:34:48 +0000 Subject: [PATCH] Reusable widgets: reference widgets across dashboards + detach to clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../routers/widgets.py | 59 +++++ .../services/settings_store.py | 105 +++++++++ .../widgets/sources.py | 11 +- backend/tests/test_widgets.py | 130 +++++++++++ frontend/src/api/widgets.ts | 41 ++++ .../src/components/WidgetConfigDialog.tsx | 205 ++++++++++++++++-- .../__tests__/WidgetConfigDialog.test.tsx | 4 + frontend/src/hooks/useWidgets.ts | 43 ++++ frontend/src/pages/Dashboard.tsx | 19 +- .../src/pages/__tests__/Dashboard.test.tsx | 1 + 10 files changed, 581 insertions(+), 37 deletions(-) diff --git a/backend/src/media_library_viewer_api/routers/widgets.py b/backend/src/media_library_viewer_api/routers/widgets.py index 56afd86..c128341 100644 --- a/backend/src/media_library_viewer_api/routers/widgets.py +++ b/backend/src/media_library_viewer_api/routers/widgets.py @@ -12,6 +12,7 @@ import time from typing import Any from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel from media_library_viewer_api.dependencies import get_settings_store from media_library_viewer_api.integrations.base import validate_config @@ -34,6 +35,15 @@ from media_library_viewer_api.widgets.sources import ( get_service_adapter, ) + +class WidgetReferenceCreate(BaseModel): + """Payload for creating a widget reference (live-link).""" + + dashboard_scope: str + widget_id: str + sort_order: int = 0 + + router = APIRouter(prefix="/api/widgets", tags=["widgets"]) logger = logging.getLogger(__name__) @@ -221,3 +231,52 @@ async def fetch_data( error=data.get("error"), fetched_at=int(time.time()), ).model_dump() + + +# --------------------------------------------------------------------------- +# Widget references (live-link widgets across dashboards) +# --------------------------------------------------------------------------- + + +@router.get("/references") +def list_references( + dashboard_scope: str, + store: SettingsStore = Depends(get_settings_store), +) -> list[dict[str, Any]]: + """List widget references for a dashboard scope.""" + return store.list_widget_references(dashboard_scope) + + +@router.post("/references", status_code=status.HTTP_201_CREATED) +def create_reference( + body: WidgetReferenceCreate, + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + """Create a widget reference (live-link) on a dashboard.""" + try: + return store.create_widget_reference(body.dashboard_scope, body.widget_id, body.sort_order) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.delete("/references/{reference_id}") +def delete_reference( + reference_id: str, + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, str]: + """Remove a widget reference from a dashboard.""" + store.delete_widget_reference(reference_id) + return {"status": "deleted"} + + +@router.post("/references/{reference_id}/detach") +def detach_reference( + reference_id: str, + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + """Clone the referenced widget into a standalone instance and remove the reference.""" + try: + cloned = store.detach_widget_reference(reference_id, "") + return WidgetInstance(**cloned).model_dump() + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc diff --git a/backend/src/media_library_viewer_api/services/settings_store.py b/backend/src/media_library_viewer_api/services/settings_store.py index f08d2f2..c7021d5 100644 --- a/backend/src/media_library_viewer_api/services/settings_store.py +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -165,6 +165,19 @@ class SettingsStore: conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT") if "widget_kind" not in widget_cols: conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS widget_references ( + id TEXT PRIMARY KEY, + dashboard_scope TEXT NOT NULL, + widget_id TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + FOREIGN KEY (widget_id) REFERENCES dashboard_widgets(id) ON DELETE CASCADE + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_widget_references_scope ON widget_references(dashboard_scope)") conn.execute(""" CREATE TABLE IF NOT EXISTS backup_jobs ( id TEXT PRIMARY KEY, @@ -1491,6 +1504,98 @@ class SettingsStore: self.init_schema() with self.connect() as conn: conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,)) + conn.execute("DELETE FROM widget_references WHERE widget_id = ?", (widget_id,)) + + # ------------------------------------------------------------------ + # Widget references (live-link widgets across dashboards) + # ------------------------------------------------------------------ + + def list_widget_references(self, dashboard_scope: str) -> list[dict[str, Any]]: + """List widget references for a dashboard scope, joined with widget data.""" + self.init_schema() + with self.connect() as conn: + rows = conn.execute( + """ + SELECT wr.id AS ref_id, wr.dashboard_scope, wr.widget_id, wr.sort_order, + wr.created_at AS ref_created_at + FROM widget_references wr + WHERE wr.dashboard_scope = ? + ORDER BY wr.sort_order ASC, wr.created_at ASC + """, + (dashboard_scope,), + ).fetchall() + result: list[dict[str, Any]] = [] + for row in rows: + widget = self.get_widget(row["widget_id"]) + if not widget: + continue + result.append( + { + "id": row["ref_id"], + "dashboard_scope": row["dashboard_scope"], + "widget_id": row["widget_id"], + "sort_order": int(row["sort_order"]), + "created_at": row["ref_created_at"], + "widget": widget, + } + ) + return result + + def create_widget_reference(self, dashboard_scope: str, widget_id: str, sort_order: int = 0) -> dict[str, Any]: + self.init_schema() + widget = self.get_widget(widget_id) + if not widget: + raise ValueError(f"Widget {widget_id} not found") + ref_id = uuid.uuid4().hex[:12] + now = int(time.time()) + with self.connect() as conn: + conn.execute( + """ + INSERT INTO widget_references (id, dashboard_scope, widget_id, sort_order, created_at) + VALUES (?, ?, ?, ?, ?) + """, + (ref_id, dashboard_scope, widget_id, sort_order, now), + ) + return { + "id": ref_id, + "dashboard_scope": dashboard_scope, + "widget_id": widget_id, + "sort_order": sort_order, + "created_at": now, + "widget": widget, + } + + def delete_widget_reference(self, reference_id: str) -> None: + self.init_schema() + with self.connect() as conn: + conn.execute("DELETE FROM widget_references WHERE id = ?", (reference_id,)) + + 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() + with self.connect() as conn: + row = conn.execute( + "SELECT widget_id FROM widget_references WHERE id = ?", + (reference_id,), + ).fetchone() + if not row: + raise ValueError(f"Reference {reference_id} not found") + 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. + cloned = self.upsert_widget( + { + "service_id": None, + "widget_kind": source["widget_kind"], + "title": source["title"], + "config": source["config"], + "enabled": source["enabled"], + "sort_order": source["sort_order"], + } + ) + self.delete_widget_reference(reference_id) + return cloned # ------------------------------------------------------------------ # Service registry diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index d1a899c..b4b2e79 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -185,21 +185,14 @@ class GrafanaWidgetSource: # Prefer displayName (explicitly set in Grafana), then Prometheus # labels (e.g. {instance: "server:9100", mode: "iowait"}), then # the field name as a last resort. - display_name = ( - value_field.get("config", {}).get("displayName") - or value_field.get("displayName") - ) + display_name = value_field.get("config", {}).get("displayName") or value_field.get("displayName") frame_labels = value_field.get("labels") or {} if display_name: label = str(display_name) elif frame_labels: # Build a readable label from the Prometheus labels, excluding # redundant ones like __name__. - parts = [ - f"{k}={v}" - for k, v in sorted(frame_labels.items()) - if not k.startswith("__") - ] + parts = [f"{k}={v}" for k, v in sorted(frame_labels.items()) if not k.startswith("__")] label = " ".join(parts) if parts else "value" else: label = value_field.get("name", "value") diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index 76f4dd7..9b249ed 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -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 diff --git a/frontend/src/api/widgets.ts b/frontend/src/api/widgets.ts index 306d314..07fb09f 100644 --- a/frontend/src/api/widgets.ts +++ b/frontend/src/api/widgets.ts @@ -6,6 +6,21 @@ import type { WidgetInstanceInput, } from "../types"; +export interface WidgetReference { + id: string; + dashboard_scope: string; + widget_id: string; + sort_order: number; + created_at: number; + widget: WidgetInstance; +} + +export interface WidgetReferenceInput { + dashboard_scope: string; + widget_id: string; + sort_order?: number; +} + export async function fetchBuiltinWidgetKinds(): Promise< BuiltinWidgetKindInfo[] > { @@ -46,3 +61,29 @@ export async function fetchWidgetData( ): Promise { return get(`/api/widgets/instances/${widgetId}/data`); } + +export async function fetchWidgetReferences( + dashboardScope: string, +): Promise { + return get("/api/widgets/references", { + dashboard_scope: dashboardScope, + }); +} + +export async function createWidgetReference( + input: WidgetReferenceInput, +): Promise { + return post("/api/widgets/references", input); +} + +export async function deleteWidgetReference( + referenceId: string, +): Promise<{ status: string }> { + return del<{ status: string }>(`/api/widgets/references/${referenceId}`); +} + +export async function detachWidgetReference( + referenceId: string, +): Promise { + return post(`/api/widgets/references/${referenceId}/detach`); +} diff --git a/frontend/src/components/WidgetConfigDialog.tsx b/frontend/src/components/WidgetConfigDialog.tsx index b360773..6e847a1 100644 --- a/frontend/src/components/WidgetConfigDialog.tsx +++ b/frontend/src/components/WidgetConfigDialog.tsx @@ -19,11 +19,23 @@ import { } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react"; import { + ChevronDown, + ChevronUp, + Link2, + Pencil, + Plus, + Trash2, + Split, +} from "lucide-react"; +import { + useCreateWidgetReference, useDeleteWidgetInstance, + useDeleteWidgetReference, + useDetachWidgetReference, useSaveWidgetInstance, useWidgetInstances, + useWidgetReferences, } from "../hooks/useWidgets"; import { useServiceInstances } from "../hooks/useServices"; import { useTasks } from "../hooks/useSettings"; @@ -41,6 +53,8 @@ interface Props { onClose: () => void; /** When set, scope the dialog to a specific service instance's widgets. */ serviceId?: string; + /** When set, enable widget references ("Add existing") for this dashboard scope. */ + dashboardScope?: string; } interface Draft { @@ -183,12 +197,24 @@ function WidgetConfigEditor({ ); } -export function WidgetConfigDialog({ open, onClose, serviceId }: Props) { +export function WidgetConfigDialog({ + open, + onClose, + serviceId, + dashboardScope, +}: Props) { const { data: instances = [] } = useWidgetInstances(serviceId); const { data: services = [] } = useServiceInstances(); const { data: tasks = [] } = useTasks(); const saveWidget = useSaveWidgetInstance(); const deleteWidget = useDeleteWidgetInstance(); + const { data: references = [] } = useWidgetReferences(dashboardScope); + const createRef = useCreateWidgetReference(); + const deleteRef = useDeleteWidgetReference(); + const detachRef = useDetachWidgetReference(); + const { data: allWidgets = [] } = useWidgetInstances(); + const [showExisting, setShowExisting] = useState(false); + const [existingSearch, setExistingSearch] = useState(""); const [draft, setDraft] = useState(null); @@ -284,6 +310,55 @@ export function WidgetConfigDialog({ open, onClose, serviceId }: Props) { await deleteWidget.mutateAsync(instance.id); } + // Build a combined view of owned widgets + references for display. + const referencedWidgetIds = new Set(references.map((r) => r.widget_id)); + const combinedWidgets = useMemo(() => { + const owned = [...instances].sort( + (a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at, + ); + const refs = references.map((r) => ({ + ...r.widget, + _ref_id: r.id, + _is_reference: true as const, + })); + return [...owned, ...refs].sort( + (a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at, + ); + }, [instances, references]); + + // Available widgets for the "Add existing" picker: all widgets not already + // on this dashboard (owned or referenced). + const availableWidgets = useMemo(() => { + const onDashboard = new Set([ + ...instances.map((w) => w.id), + ...referencedWidgetIds, + ]); + const search = existingSearch.toLowerCase().trim(); + return allWidgets + .filter((w) => !onDashboard.has(w.id)) + .filter( + (w) => + !search || + w.title.toLowerCase().includes(search) || + w.widget_kind.toLowerCase().includes(search), + ); + }, [allWidgets, instances, referencedWidgetIds, existingSearch]); + + async function handleAddReference(widgetId: string) { + await createRef.mutateAsync({ + dashboard_scope: dashboardScope!, + widget_id: widgetId, + }); + } + + async function handleRemoveReference(refId: string) { + await deleteRef.mutateAsync(refId); + } + + async function handleDetach(refId: string) { + await detachRef.mutateAsync(refId); + } + function handleClose(next: boolean) { if (!next) { reset(); @@ -370,16 +445,19 @@ export function WidgetConfigDialog({ open, onClose, serviceId }: Props) { ) : (
- {sortedInstances.length === 0 ? ( + {combinedWidgets.length === 0 ? ( No widgets yet. Add one below. ) : (
- {sortedInstances.map((instance, index) => { + {combinedWidgets.map((instance, index) => { const serviceName = instance.service_id ? services.find((s) => s.id === instance.service_id)?.name : "Built-in"; + const isRef = + (instance as { _is_reference?: boolean })._is_reference === true; + const refId = (instance as { _ref_id?: string })._ref_id; return (
{instance.title} + {isRef ? ( + + + linked + + ) : null} {bindingLabel(instance.service_id, instance.widget_kind)} @@ -415,30 +499,52 @@ export function WidgetConfigDialog({ open, onClose, serviceId }: Props) { variant="ghost" size="icon" className="mobile-touch-target h-8 w-8" - disabled={index === sortedInstances.length - 1} + disabled={index === combinedWidgets.length - 1} onClick={() => moveInstance(index, 1)} > - toggleEnabled(instance)} - aria-label={`Toggle ${instance.title}`} - /> - + {!isRef ? ( + toggleEnabled(instance)} + aria-label={`Toggle ${instance.title}`} + /> + ) : null} + {!isRef ? ( + + ) : null} + {isRef && refId ? ( + + ) : null} @@ -449,6 +555,65 @@ export function WidgetConfigDialog({ open, onClose, serviceId }: Props) {
)} + {dashboardScope ? ( +
+ + {showExisting ? ( +
+ setExistingSearch(e.target.value)} + /> + {availableWidgets.length === 0 ? ( +

+ No widgets available to reuse. +

+ ) : ( +
+ {availableWidgets.map((w) => { + const owner = w.service_id + ? services.find((s) => s.id === w.service_id)?.name + : "Dashboard"; + return ( +
+
+ {w.title} + + {bindingLabel(w.service_id, w.widget_kind)} ·{" "} + {owner} + +
+ +
+ ); + })} +
+ )} +
+ ) : null} +
+ ) : null} +

Add widget

diff --git a/frontend/src/components/__tests__/WidgetConfigDialog.test.tsx b/frontend/src/components/__tests__/WidgetConfigDialog.test.tsx index baab0ed..2482069 100644 --- a/frontend/src/components/__tests__/WidgetConfigDialog.test.tsx +++ b/frontend/src/components/__tests__/WidgetConfigDialog.test.tsx @@ -18,8 +18,12 @@ function setMatchMedia(matches: boolean) { vi.mock("../../hooks/useWidgets", () => ({ useWidgetInstances: () => ({ data: [] }), + useWidgetReferences: () => ({ data: [] }), useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }), useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }), + useCreateWidgetReference: () => ({ mutateAsync: vi.fn() }), + useDeleteWidgetReference: () => ({ mutateAsync: vi.fn() }), + useDetachWidgetReference: () => ({ mutateAsync: vi.fn() }), })); vi.mock("../../hooks/useServices", () => ({ diff --git a/frontend/src/hooks/useWidgets.ts b/frontend/src/hooks/useWidgets.ts index 3cced74..c259f97 100644 --- a/frontend/src/hooks/useWidgets.ts +++ b/frontend/src/hooks/useWidgets.ts @@ -1,10 +1,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createWidgetInstance, + createWidgetReference, deleteWidgetInstance, + deleteWidgetReference, + detachWidgetReference, fetchBuiltinWidgetKinds, fetchWidgetData, fetchWidgetInstances, + fetchWidgetReferences, updateWidgetInstance, } from "../api/widgets"; import type { WidgetInstanceInput } from "../types"; @@ -58,3 +62,42 @@ export function useBuiltinWidgetKinds() { staleTime: 5 * 60 * 1000, }); } + +export function useWidgetReferences(dashboardScope: string | undefined) { + return useQuery({ + queryKey: ["widgets", "references", dashboardScope ?? null], + queryFn: () => fetchWidgetReferences(dashboardScope!), + enabled: !!dashboardScope, + refetchInterval: 60_000, + }); +} + +export function useCreateWidgetReference() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createWidgetReference, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["widgets", "references"] }); + }, + }); +} + +export function useDeleteWidgetReference() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteWidgetReference, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["widgets", "references"] }); + }, + }); +} + +export function useDetachWidgetReference() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: detachWidgetReference, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["widgets"] }); + }, + }); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index cc6ba4c..788c630 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -31,7 +31,7 @@ import { useDeleteDashboardShortcut, useSaveDashboardShortcut, } from "../hooks/useDashboard"; -import { useWidgetInstances } from "../hooks/useWidgets"; +import { useWidgetInstances, useWidgetReferences } from "../hooks/useWidgets"; import { useServiceInstances } from "../hooks/useServices"; import { useIsMobile } from "../hooks/useIsMobile"; import type { @@ -452,16 +452,18 @@ export function Dashboard() { undefined, "dashboard", ); + const { data: widgetReferences = [] } = useWidgetReferences("main"); const { data: services = [] } = useServiceInstances(); const isMobile = useIsMobile(); - const visibleWidgets = useMemo( - () => - widgetInstances - .filter((w) => w.enabled) - .sort((a, b) => a.sort_order - b.sort_order), - [widgetInstances], - ); + const visibleWidgets = useMemo(() => { + const refs = widgetReferences + .filter((r) => r.widget.enabled) + .map((r) => r.widget); + return [...widgetInstances, ...refs] + .filter((w) => w.enabled) + .sort((a, b) => a.sort_order - b.sort_order); + }, [widgetInstances, widgetReferences]); const mobileSections = useMemo( () => groupWidgetsBySection(visibleWidgets, services), @@ -592,6 +594,7 @@ export function Dashboard() { setWidgetDialogOpen(false)} + dashboardScope="main" />
); diff --git a/frontend/src/pages/__tests__/Dashboard.test.tsx b/frontend/src/pages/__tests__/Dashboard.test.tsx index 3e4d99b..9201c57 100644 --- a/frontend/src/pages/__tests__/Dashboard.test.tsx +++ b/frontend/src/pages/__tests__/Dashboard.test.tsx @@ -23,6 +23,7 @@ vi.mock("../../hooks/useSettings", () => ({ })); vi.mock("../../hooks/useWidgets", () => ({ useWidgetInstances: () => ({ data: [] }), + useWidgetReferences: () => ({ data: [] }), })); vi.mock("../../hooks/useServices", () => ({ useServiceInstances: () => ({ data: [] }),