diff --git a/frontend/src/components/WidgetConfigDialog.tsx b/frontend/src/components/WidgetConfigDialog.tsx index 0c6fa21..5638792 100644 --- a/frontend/src/components/WidgetConfigDialog.tsx +++ b/frontend/src/components/WidgetConfigDialog.tsx @@ -227,6 +227,7 @@ export function WidgetConfigDialog({ const [existingSearch, setExistingSearch] = useState(""); const [draft, setDraft] = useState(null); + const [draftBaseline, setDraftBaseline] = useState(null); // When editWidgetId is set and the dialog opens, auto-enter edit mode for // that widget (instead of showing the list view). @@ -266,7 +267,7 @@ export function WidgetConfigDialog({ } function startEdit(instance: WidgetInstance) { - setDraft({ + const d: Draft = { id: instance.id, serviceId: instance.service_id, widgetKind: instance.widget_kind, @@ -274,11 +275,14 @@ export function WidgetConfigDialog({ config: instance.config, enabled: instance.enabled, sortOrder: instance.sort_order, - }); + }; + setDraft(d); + setDraftBaseline(d); } function reset() { setDraft(null); + setDraftBaseline(null); } async function saveDraft() { @@ -701,7 +705,11 @@ export function WidgetConfigDialog({ onCancel={draft ? reset : () => handleClose(false)} saveLabel={draft ? "Save widget" : "Done"} isPending={draft ? saveWidget.isPending : false} - isDirty={draft !== null} + isDirty={ + draft !== null && draftBaseline !== null + ? JSON.stringify(draft) !== JSON.stringify(draftBaseline) + : draft !== null && draft?.id === undefined + } >
{draftBody}
diff --git a/frontend/src/components/WidgetInstance.tsx b/frontend/src/components/WidgetInstance.tsx index e3d80cb..3d6e017 100644 --- a/frontend/src/components/WidgetInstance.tsx +++ b/frontend/src/components/WidgetInstance.tsx @@ -1,6 +1,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Settings2 } from "lucide-react"; +import { Settings2, Copy } from "lucide-react"; import { useServiceInstances } from "../hooks/useServices"; import { resolveWidget } from "../integrations/registry"; import type { WidgetInstance } from "../types"; @@ -8,31 +8,51 @@ import { SectionCard } from "./SectionCard"; interface Props { widget: WidgetInstance; - /** When provided, a hover-reveal edit button appears in the top-right corner. */ + /** When provided, an edit button appears in the top-right corner (hover on desktop, always on mobile). */ onEdit?: (widgetId: string) => void; + /** When provided, a copy/detach button appears next to edit (for referenced widgets). */ + onCopy?: (widgetId: string) => void; } -export function WidgetInstanceCard({ widget, onEdit }: Props) { +export function WidgetInstanceCard({ widget, onEdit, onCopy }: Props) { const { data: services = [] } = useServiceInstances(); const resolved = resolveWidget(widget, services); + // Edit + copy buttons: always visible on mobile (below md), hover-reveal on desktop. + const actionButtons = ( +
+ {onCopy ? ( + + ) : null} + {onEdit ? ( + + ) : null} +
+ ); + if (!resolved) { const label = widget.service_id ? `Unknown widget: ${widget.widget_kind} (service-bound)` : `Unknown widget: ${widget.widget_kind} (built-in)`; return (
- {onEdit ? ( - - ) : null} + {(onEdit || onCopy) ? actionButtons : null} {label} @@ -45,17 +65,7 @@ export function WidgetInstanceCard({ widget, onEdit }: Props) { const Component = resolved.component; return (
- {onEdit ? ( - - ) : null} + {(onEdit || onCopy) ? actionButtons : null} {section.widgets.map((widget) => ( - onEditWidget(id) : undefined} /> + onEditWidget(id) : undefined} + /> ))} ))} -
+
); } @@ -468,6 +472,13 @@ export function Dashboard() { .sort((a, b) => a.sort_order - b.sort_order); }, [widgetInstances, widgetReferences]); + // Track which visible widgets are references (for the copy/detach button). + const referencedWidgetIds = useMemo( + () => new Set(widgetReferences.map((r) => r.widget.id)), + [widgetReferences], + ); + const detachRef = useDetachWidgetReference(); + const mobileSections = useMemo( () => groupWidgetsBySection(visibleWidgets, services), [visibleWidgets, services], @@ -567,10 +578,32 @@ export function Dashboard() { {isMobile && mobileSections.length > 0 ? ( - { setEditWidgetId(id); setWidgetDialogOpen(true); }} /> + { + setEditWidgetId(id); + setWidgetDialogOpen(true); + }} + /> ) : ( visibleWidgets.map((widget) => ( - { setEditWidgetId(id); setWidgetDialogOpen(true); }} /> + { + setEditWidgetId(id); + setWidgetDialogOpen(true); + }} + onCopy={ + referencedWidgetIds.has(widget.id) + ? () => { + // Detach: find the reference and clone it. + const ref = widgetReferences.find((r) => r.widget.id === widget.id); + if (ref) detachRef.mutate(ref.id); + } + : undefined + } + /> )) )} @@ -596,7 +629,10 @@ export function Dashboard() { /> { setWidgetDialogOpen(false); setEditWidgetId(undefined); }} + onClose={() => { + setWidgetDialogOpen(false); + setEditWidgetId(undefined); + }} dashboardScope="main" editWidgetId={editWidgetId} /> diff --git a/frontend/src/pages/NamedDashboardPage.tsx b/frontend/src/pages/NamedDashboardPage.tsx index db848b6..ac797e8 100644 --- a/frontend/src/pages/NamedDashboardPage.tsx +++ b/frontend/src/pages/NamedDashboardPage.tsx @@ -97,7 +97,14 @@ export function NamedDashboardPage() { {visibleWidgets.length > 0 ? (
{visibleWidgets.map((widget) => ( - { setEditWidgetId(id); setConfigOpen(true); }} /> + { + setEditWidgetId(id); + setConfigOpen(true); + }} + /> ))}
) : null} @@ -125,7 +132,10 @@ export function NamedDashboardPage() { { setConfigOpen(false); setEditWidgetId(undefined); }} + onClose={() => { + setConfigOpen(false); + setEditWidgetId(undefined); + }} dashboardScope={dashboardScope} editWidgetId={editWidgetId} /> diff --git a/frontend/src/pages/__tests__/Dashboard.test.tsx b/frontend/src/pages/__tests__/Dashboard.test.tsx index 9201c57..32e46bb 100644 --- a/frontend/src/pages/__tests__/Dashboard.test.tsx +++ b/frontend/src/pages/__tests__/Dashboard.test.tsx @@ -24,6 +24,7 @@ vi.mock("../../hooks/useSettings", () => ({ vi.mock("../../hooks/useWidgets", () => ({ useWidgetInstances: () => ({ data: [] }), useWidgetReferences: () => ({ data: [] }), + useDetachWidgetReference: () => ({ mutate: () => {} }), })); vi.mock("../../hooks/useServices", () => ({ useServiceInstances: () => ({ data: [] }), diff --git a/frontend/src/pages/service-tabs/OverviewTab.tsx b/frontend/src/pages/service-tabs/OverviewTab.tsx index 3a2dc04..f12ea8c 100644 --- a/frontend/src/pages/service-tabs/OverviewTab.tsx +++ b/frontend/src/pages/service-tabs/OverviewTab.tsx @@ -49,7 +49,14 @@ export function OverviewTab({ instance }: { instance: ServiceInstance }) { {visibleWidgets.length > 0 ? (
{visibleWidgets.map((widget) => ( - { setEditWidgetId(id); setConfigOpen(true); }} /> + { + setEditWidgetId(id); + setConfigOpen(true); + }} + /> ))}
) : ( @@ -72,7 +79,10 @@ export function OverviewTab({ instance }: { instance: ServiceInstance }) { { setConfigOpen(false); setEditWidgetId(undefined); }} + onClose={() => { + setConfigOpen(false); + setEditWidgetId(undefined); + }} serviceId={instance.id} editWidgetId={editWidgetId} />