From 04871bd7d405beba842531cfafc59f7f3d96a24e Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 6 Jul 2026 14:26:58 +0000 Subject: [PATCH] Fix: dialog scroll, isDirty false positive, mobile edit btn, widget copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes: 1. Dialog mobile scroll: DialogContent now has max-h-[calc(100dvh-2rem)] overflow-y-auto so dialogs that don't fit on screen can scroll instead of clipping their footer (and Cancel button) off-screen. 2. isDirty false positive: WidgetConfigDialog's SheetForm used isDirty={draft !== null} which was true the moment you opened edit mode, even with no changes. Now stores a draftBaseline at startEdit time and compares JSON.stringify(draft) !== JSON.stringify(baseline). The discard-confirmation only appears when something actually changed. 3. Mobile edit button always visible: the widget card's edit button was opacity-0 group-hover:opacity-100 (hover-only). Changed to md:opacity-0 md:group-hover:opacity-100 — always visible below md, hover-reveal at md+. 4. Copy button for referenced widgets: WidgetInstanceCard gains an onCopy prop. On the Dashboard, referenced widgets get a Copy icon button that triggers detachRef (creates an independent clone). The edit button on referenced widgets edits the original (shared config). 5. ConfirmDialog Cancel: fixed by #1 (the Cancel button was off-screen on mobile dialogs that couldn't scroll). 128 tests pass; lint/build green. --- .../src/components/WidgetConfigDialog.tsx | 14 ++++- frontend/src/components/WidgetInstance.tsx | 60 +++++++++++-------- frontend/src/components/ui/dialog.tsx | 2 +- frontend/src/pages/Dashboard.tsx | 48 +++++++++++++-- frontend/src/pages/NamedDashboardPage.tsx | 14 ++++- .../src/pages/__tests__/Dashboard.test.tsx | 1 + .../src/pages/service-tabs/OverviewTab.tsx | 14 ++++- 7 files changed, 114 insertions(+), 39 deletions(-) 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} />