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:
Developer
2026-07-06 11:34:48 +00:00
parent 94bf830955
commit c36262d7b6
10 changed files with 581 additions and 37 deletions
@@ -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
@@ -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
@@ -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")
+130
View File
@@ -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
+41
View File
@@ -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<WidgetDataResponse> {
return get<WidgetDataResponse>(`/api/widgets/instances/${widgetId}/data`);
}
export async function fetchWidgetReferences(
dashboardScope: string,
): Promise<WidgetReference[]> {
return get<WidgetReference[]>("/api/widgets/references", {
dashboard_scope: dashboardScope,
});
}
export async function createWidgetReference(
input: WidgetReferenceInput,
): Promise<WidgetReference> {
return post<WidgetReference>("/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<WidgetInstance> {
return post<WidgetInstance>(`/api/widgets/references/${referenceId}/detach`);
}
+185 -20
View File
@@ -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<Draft | null>(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) {
</div>
) : (
<div className="flex flex-col gap-4">
{sortedInstances.length === 0 ? (
{combinedWidgets.length === 0 ? (
<Alert>
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
</Alert>
) : (
<div className="flex flex-col gap-2">
{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 (
<div
key={instance.id}
@@ -388,6 +466,12 @@ export function WidgetConfigDialog({ open, onClose, serviceId }: Props) {
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{instance.title}</span>
{isRef ? (
<Badge variant="secondary">
<Link2 className="mr-1 h-3 w-3" />
linked
</Badge>
) : null}
<Badge variant="outline">
{bindingLabel(instance.service_id, instance.widget_kind)}
</Badge>
@@ -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)}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Switch
className="mobile-touch-target"
checked={instance.enabled}
onCheckedChange={() => toggleEnabled(instance)}
aria-label={`Toggle ${instance.title}`}
/>
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
onClick={() => startEdit(instance)}
>
<Pencil className="h-4 w-4" />
</Button>
{!isRef ? (
<Switch
className="mobile-touch-target"
checked={instance.enabled}
onCheckedChange={() => toggleEnabled(instance)}
aria-label={`Toggle ${instance.title}`}
/>
) : null}
{!isRef ? (
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
onClick={() => startEdit(instance)}
>
<Pencil className="h-4 w-4" />
</Button>
) : null}
{isRef && refId ? (
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
title="Make an independent copy"
onClick={() => handleDetach(refId)}
>
<Split className="h-4 w-4" />
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8 text-destructive"
onClick={() => removeInstance(instance)}
title={
isRef ? "Remove from this dashboard" : "Delete widget"
}
onClick={() =>
isRef && refId
? handleRemoveReference(refId)
: removeInstance(instance)
}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -449,6 +555,65 @@ export function WidgetConfigDialog({ open, onClose, serviceId }: Props) {
</div>
)}
{dashboardScope ? (
<div className="flex flex-col gap-2">
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => setShowExisting(!showExisting)}
>
<Link2 className="mr-1 h-3 w-3" />
{showExisting ? "Hide" : "Add existing widget"}
</Button>
{showExisting ? (
<div className="flex flex-col gap-2">
<Input
placeholder="Search widgets..."
value={existingSearch}
onChange={(e) => setExistingSearch(e.target.value)}
/>
{availableWidgets.length === 0 ? (
<p className="text-xs text-muted-foreground">
No widgets available to reuse.
</p>
) : (
<div className="flex flex-col gap-1">
{availableWidgets.map((w) => {
const owner = w.service_id
? services.find((s) => s.id === w.service_id)?.name
: "Dashboard";
return (
<div
key={w.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col">
<span className="text-sm font-medium">{w.title}</span>
<span className="text-xs text-muted-foreground">
{bindingLabel(w.service_id, w.widget_kind)} ·{" "}
{owner}
</span>
</div>
<Button
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => handleAddReference(w.id)}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
);
})}
</div>
)}
</div>
) : null}
</div>
) : null}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Add widget</p>
<div className="flex flex-wrap gap-2">
@@ -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", () => ({
+43
View File
@@ -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"] });
},
});
}
+11 -8
View File
@@ -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() {
<WidgetConfigDialog
open={widgetDialogOpen}
onClose={() => setWidgetDialogOpen(false)}
dashboardScope="main"
/>
</div>
);
@@ -23,6 +23,7 @@ vi.mock("../../hooks/useSettings", () => ({
}));
vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: [] }),
useWidgetReferences: () => ({ data: [] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [] }),