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:
@@ -12,6 +12,7 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
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.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.integrations.base import validate_config
|
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,
|
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"])
|
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -221,3 +231,52 @@ async def fetch_data(
|
|||||||
error=data.get("error"),
|
error=data.get("error"),
|
||||||
fetched_at=int(time.time()),
|
fetched_at=int(time.time()),
|
||||||
).model_dump()
|
).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")
|
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
||||||
if "widget_kind" not in widget_cols:
|
if "widget_kind" not in widget_cols:
|
||||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
|
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("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -1491,6 +1504,98 @@ class SettingsStore:
|
|||||||
self.init_schema()
|
self.init_schema()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
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
|
# Service registry
|
||||||
|
|||||||
@@ -185,21 +185,14 @@ class GrafanaWidgetSource:
|
|||||||
# Prefer displayName (explicitly set in Grafana), then Prometheus
|
# Prefer displayName (explicitly set in Grafana), then Prometheus
|
||||||
# labels (e.g. {instance: "server:9100", mode: "iowait"}), then
|
# labels (e.g. {instance: "server:9100", mode: "iowait"}), then
|
||||||
# the field name as a last resort.
|
# the field name as a last resort.
|
||||||
display_name = (
|
display_name = value_field.get("config", {}).get("displayName") or value_field.get("displayName")
|
||||||
value_field.get("config", {}).get("displayName")
|
|
||||||
or value_field.get("displayName")
|
|
||||||
)
|
|
||||||
frame_labels = value_field.get("labels") or {}
|
frame_labels = value_field.get("labels") or {}
|
||||||
if display_name:
|
if display_name:
|
||||||
label = str(display_name)
|
label = str(display_name)
|
||||||
elif frame_labels:
|
elif frame_labels:
|
||||||
# Build a readable label from the Prometheus labels, excluding
|
# Build a readable label from the Prometheus labels, excluding
|
||||||
# redundant ones like __name__.
|
# redundant ones like __name__.
|
||||||
parts = [
|
parts = [f"{k}={v}" for k, v in sorted(frame_labels.items()) if not k.startswith("__")]
|
||||||
f"{k}={v}"
|
|
||||||
for k, v in sorted(frame_labels.items())
|
|
||||||
if not k.startswith("__")
|
|
||||||
]
|
|
||||||
label = " ".join(parts) if parts else "value"
|
label = " ".join(parts) if parts else "value"
|
||||||
else:
|
else:
|
||||||
label = value_field.get("name", "value")
|
label = value_field.get("name", "value")
|
||||||
|
|||||||
@@ -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):
|
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
|
||||||
result = await adapter.fetch(service, "activity", {})
|
result = await adapter.fetch(service, "activity", {})
|
||||||
assert len(result["sessions"]) == 2
|
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
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ import type {
|
|||||||
WidgetInstanceInput,
|
WidgetInstanceInput,
|
||||||
} from "../types";
|
} 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<
|
export async function fetchBuiltinWidgetKinds(): Promise<
|
||||||
BuiltinWidgetKindInfo[]
|
BuiltinWidgetKindInfo[]
|
||||||
> {
|
> {
|
||||||
@@ -46,3 +61,29 @@ export async function fetchWidgetData(
|
|||||||
): Promise<WidgetDataResponse> {
|
): Promise<WidgetDataResponse> {
|
||||||
return get<WidgetDataResponse>(`/api/widgets/instances/${widgetId}/data`);
|
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`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,11 +19,23 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Link2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Split,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCreateWidgetReference,
|
||||||
useDeleteWidgetInstance,
|
useDeleteWidgetInstance,
|
||||||
|
useDeleteWidgetReference,
|
||||||
|
useDetachWidgetReference,
|
||||||
useSaveWidgetInstance,
|
useSaveWidgetInstance,
|
||||||
useWidgetInstances,
|
useWidgetInstances,
|
||||||
|
useWidgetReferences,
|
||||||
} from "../hooks/useWidgets";
|
} from "../hooks/useWidgets";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useTasks } from "../hooks/useSettings";
|
import { useTasks } from "../hooks/useSettings";
|
||||||
@@ -41,6 +53,8 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** When set, scope the dialog to a specific service instance's widgets. */
|
/** When set, scope the dialog to a specific service instance's widgets. */
|
||||||
serviceId?: string;
|
serviceId?: string;
|
||||||
|
/** When set, enable widget references ("Add existing") for this dashboard scope. */
|
||||||
|
dashboardScope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Draft {
|
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: instances = [] } = useWidgetInstances(serviceId);
|
||||||
const { data: services = [] } = useServiceInstances();
|
const { data: services = [] } = useServiceInstances();
|
||||||
const { data: tasks = [] } = useTasks();
|
const { data: tasks = [] } = useTasks();
|
||||||
const saveWidget = useSaveWidgetInstance();
|
const saveWidget = useSaveWidgetInstance();
|
||||||
const deleteWidget = useDeleteWidgetInstance();
|
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);
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
|
||||||
@@ -284,6 +310,55 @@ export function WidgetConfigDialog({ open, onClose, serviceId }: Props) {
|
|||||||
await deleteWidget.mutateAsync(instance.id);
|
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) {
|
function handleClose(next: boolean) {
|
||||||
if (!next) {
|
if (!next) {
|
||||||
reset();
|
reset();
|
||||||
@@ -370,16 +445,19 @@ export function WidgetConfigDialog({ open, onClose, serviceId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{sortedInstances.length === 0 ? (
|
{combinedWidgets.length === 0 ? (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
|
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{sortedInstances.map((instance, index) => {
|
{combinedWidgets.map((instance, index) => {
|
||||||
const serviceName = instance.service_id
|
const serviceName = instance.service_id
|
||||||
? services.find((s) => s.id === instance.service_id)?.name
|
? services.find((s) => s.id === instance.service_id)?.name
|
||||||
: "Built-in";
|
: "Built-in";
|
||||||
|
const isRef =
|
||||||
|
(instance as { _is_reference?: boolean })._is_reference === true;
|
||||||
|
const refId = (instance as { _ref_id?: string })._ref_id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={instance.id}
|
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 flex-1 flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{instance.title}</span>
|
<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">
|
<Badge variant="outline">
|
||||||
{bindingLabel(instance.service_id, instance.widget_kind)}
|
{bindingLabel(instance.service_id, instance.widget_kind)}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -415,30 +499,52 @@ export function WidgetConfigDialog({ open, onClose, serviceId }: Props) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="mobile-touch-target h-8 w-8"
|
className="mobile-touch-target h-8 w-8"
|
||||||
disabled={index === sortedInstances.length - 1}
|
disabled={index === combinedWidgets.length - 1}
|
||||||
onClick={() => moveInstance(index, 1)}
|
onClick={() => moveInstance(index, 1)}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Switch
|
{!isRef ? (
|
||||||
className="mobile-touch-target"
|
<Switch
|
||||||
checked={instance.enabled}
|
className="mobile-touch-target"
|
||||||
onCheckedChange={() => toggleEnabled(instance)}
|
checked={instance.enabled}
|
||||||
aria-label={`Toggle ${instance.title}`}
|
onCheckedChange={() => toggleEnabled(instance)}
|
||||||
/>
|
aria-label={`Toggle ${instance.title}`}
|
||||||
<Button
|
/>
|
||||||
variant="ghost"
|
) : null}
|
||||||
size="icon"
|
{!isRef ? (
|
||||||
className="mobile-touch-target h-8 w-8"
|
<Button
|
||||||
onClick={() => startEdit(instance)}
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<Pencil className="h-4 w-4" />
|
className="mobile-touch-target h-8 w-8"
|
||||||
</Button>
|
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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="mobile-touch-target h-8 w-8 text-destructive"
|
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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -449,6 +555,65 @@ export function WidgetConfigDialog({ open, onClose, serviceId }: Props) {
|
|||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-sm font-medium">Add widget</p>
|
<p className="text-sm font-medium">Add widget</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ function setMatchMedia(matches: boolean) {
|
|||||||
|
|
||||||
vi.mock("../../hooks/useWidgets", () => ({
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
useWidgetInstances: () => ({ data: [] }),
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
|
useWidgetReferences: () => ({ data: [] }),
|
||||||
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
useDeleteWidgetInstance: () => ({ 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", () => ({
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createWidgetInstance,
|
createWidgetInstance,
|
||||||
|
createWidgetReference,
|
||||||
deleteWidgetInstance,
|
deleteWidgetInstance,
|
||||||
|
deleteWidgetReference,
|
||||||
|
detachWidgetReference,
|
||||||
fetchBuiltinWidgetKinds,
|
fetchBuiltinWidgetKinds,
|
||||||
fetchWidgetData,
|
fetchWidgetData,
|
||||||
fetchWidgetInstances,
|
fetchWidgetInstances,
|
||||||
|
fetchWidgetReferences,
|
||||||
updateWidgetInstance,
|
updateWidgetInstance,
|
||||||
} from "../api/widgets";
|
} from "../api/widgets";
|
||||||
import type { WidgetInstanceInput } from "../types";
|
import type { WidgetInstanceInput } from "../types";
|
||||||
@@ -58,3 +62,42 @@ export function useBuiltinWidgetKinds() {
|
|||||||
staleTime: 5 * 60 * 1000,
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
useDeleteDashboardShortcut,
|
useDeleteDashboardShortcut,
|
||||||
useSaveDashboardShortcut,
|
useSaveDashboardShortcut,
|
||||||
} from "../hooks/useDashboard";
|
} from "../hooks/useDashboard";
|
||||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
import { useWidgetInstances, useWidgetReferences } from "../hooks/useWidgets";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useIsMobile } from "../hooks/useIsMobile";
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type {
|
import type {
|
||||||
@@ -452,16 +452,18 @@ export function Dashboard() {
|
|||||||
undefined,
|
undefined,
|
||||||
"dashboard",
|
"dashboard",
|
||||||
);
|
);
|
||||||
|
const { data: widgetReferences = [] } = useWidgetReferences("main");
|
||||||
const { data: services = [] } = useServiceInstances();
|
const { data: services = [] } = useServiceInstances();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const visibleWidgets = useMemo(
|
const visibleWidgets = useMemo(() => {
|
||||||
() =>
|
const refs = widgetReferences
|
||||||
widgetInstances
|
.filter((r) => r.widget.enabled)
|
||||||
.filter((w) => w.enabled)
|
.map((r) => r.widget);
|
||||||
.sort((a, b) => a.sort_order - b.sort_order),
|
return [...widgetInstances, ...refs]
|
||||||
[widgetInstances],
|
.filter((w) => w.enabled)
|
||||||
);
|
.sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
}, [widgetInstances, widgetReferences]);
|
||||||
|
|
||||||
const mobileSections = useMemo(
|
const mobileSections = useMemo(
|
||||||
() => groupWidgetsBySection(visibleWidgets, services),
|
() => groupWidgetsBySection(visibleWidgets, services),
|
||||||
@@ -592,6 +594,7 @@ export function Dashboard() {
|
|||||||
<WidgetConfigDialog
|
<WidgetConfigDialog
|
||||||
open={widgetDialogOpen}
|
open={widgetDialogOpen}
|
||||||
onClose={() => setWidgetDialogOpen(false)}
|
onClose={() => setWidgetDialogOpen(false)}
|
||||||
|
dashboardScope="main"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ vi.mock("../../hooks/useSettings", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("../../hooks/useWidgets", () => ({
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
useWidgetInstances: () => ({ data: [] }),
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
|
useWidgetReferences: () => ({ data: [] }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../hooks/useServices", () => ({
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
useServiceInstances: () => ({ data: [] }),
|
useServiceInstances: () => ({ data: [] }),
|
||||||
|
|||||||
Reference in New Issue
Block a user