Fix: dialog scroll, isDirty false positive, mobile edit btn, widget copy
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.
This commit is contained in:
@@ -227,6 +227,7 @@ export function WidgetConfigDialog({
|
|||||||
const [existingSearch, setExistingSearch] = useState("");
|
const [existingSearch, setExistingSearch] = useState("");
|
||||||
|
|
||||||
const [draft, setDraft] = useState<Draft | null>(null);
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [draftBaseline, setDraftBaseline] = useState<Draft | null>(null);
|
||||||
|
|
||||||
// When editWidgetId is set and the dialog opens, auto-enter edit mode for
|
// When editWidgetId is set and the dialog opens, auto-enter edit mode for
|
||||||
// that widget (instead of showing the list view).
|
// that widget (instead of showing the list view).
|
||||||
@@ -266,7 +267,7 @@ export function WidgetConfigDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(instance: WidgetInstance) {
|
function startEdit(instance: WidgetInstance) {
|
||||||
setDraft({
|
const d: Draft = {
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
serviceId: instance.service_id,
|
serviceId: instance.service_id,
|
||||||
widgetKind: instance.widget_kind,
|
widgetKind: instance.widget_kind,
|
||||||
@@ -274,11 +275,14 @@ export function WidgetConfigDialog({
|
|||||||
config: instance.config,
|
config: instance.config,
|
||||||
enabled: instance.enabled,
|
enabled: instance.enabled,
|
||||||
sortOrder: instance.sort_order,
|
sortOrder: instance.sort_order,
|
||||||
});
|
};
|
||||||
|
setDraft(d);
|
||||||
|
setDraftBaseline(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
|
setDraftBaseline(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDraft() {
|
async function saveDraft() {
|
||||||
@@ -701,7 +705,11 @@ export function WidgetConfigDialog({
|
|||||||
onCancel={draft ? reset : () => handleClose(false)}
|
onCancel={draft ? reset : () => handleClose(false)}
|
||||||
saveLabel={draft ? "Save widget" : "Done"}
|
saveLabel={draft ? "Save widget" : "Done"}
|
||||||
isPending={draft ? saveWidget.isPending : false}
|
isPending={draft ? saveWidget.isPending : false}
|
||||||
isDirty={draft !== null}
|
isDirty={
|
||||||
|
draft !== null && draftBaseline !== null
|
||||||
|
? JSON.stringify(draft) !== JSON.stringify(draftBaseline)
|
||||||
|
: draft !== null && draft?.id === undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">{draftBody}</div>
|
<div className="flex flex-col gap-4">{draftBody}</div>
|
||||||
</SheetForm>
|
</SheetForm>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Settings2 } from "lucide-react";
|
import { Settings2, Copy } from "lucide-react";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { resolveWidget } from "../integrations/registry";
|
import { resolveWidget } from "../integrations/registry";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
@@ -8,31 +8,51 @@ import { SectionCard } from "./SectionCard";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
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;
|
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 { data: services = [] } = useServiceInstances();
|
||||||
const resolved = resolveWidget(widget, services);
|
const resolved = resolveWidget(widget, services);
|
||||||
|
|
||||||
|
// Edit + copy buttons: always visible on mobile (below md), hover-reveal on desktop.
|
||||||
|
const actionButtons = (
|
||||||
|
<div className="absolute right-2 top-2 z-10 flex items-center gap-1">
|
||||||
|
{onCopy ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100 mobile-touch-target"
|
||||||
|
onClick={() => onCopy(widget.id)}
|
||||||
|
aria-label="Create independent copy"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{onEdit ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100 mobile-touch-target"
|
||||||
|
onClick={() => onEdit(widget.id)}
|
||||||
|
aria-label="Edit widget"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
const label = widget.service_id
|
const label = widget.service_id
|
||||||
? `Unknown widget: ${widget.widget_kind} (service-bound)`
|
? `Unknown widget: ${widget.widget_kind} (service-bound)`
|
||||||
: `Unknown widget: ${widget.widget_kind} (built-in)`;
|
: `Unknown widget: ${widget.widget_kind} (built-in)`;
|
||||||
return (
|
return (
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
{onEdit ? (
|
{(onEdit || onCopy) ? actionButtons : null}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="absolute right-2 top-2 z-10 opacity-0 transition-opacity group-hover:opacity-100 mobile-touch-target"
|
|
||||||
onClick={() => onEdit(widget.id)}
|
|
||||||
aria-label="Edit widget"
|
|
||||||
>
|
|
||||||
<Settings2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<SectionCard title={widget.title}>
|
<SectionCard title={widget.title}>
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>{label}</AlertDescription>
|
<AlertDescription>{label}</AlertDescription>
|
||||||
@@ -45,17 +65,7 @@ export function WidgetInstanceCard({ widget, onEdit }: Props) {
|
|||||||
const Component = resolved.component;
|
const Component = resolved.component;
|
||||||
return (
|
return (
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
{onEdit ? (
|
{(onEdit || onCopy) ? actionButtons : null}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="absolute right-2 top-2 z-10 opacity-0 transition-opacity group-hover:opacity-100 mobile-touch-target"
|
|
||||||
onClick={() => onEdit(widget.id)}
|
|
||||||
aria-label="Edit widget"
|
|
||||||
>
|
|
||||||
<Settings2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Component
|
<Component
|
||||||
widget={widget}
|
widget={widget}
|
||||||
refreshIntervalMs={resolved.refreshIntervalMs}
|
refreshIntervalMs={resolved.refreshIntervalMs}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
useDeleteDashboardShortcut,
|
useDeleteDashboardShortcut,
|
||||||
useSaveDashboardShortcut,
|
useSaveDashboardShortcut,
|
||||||
} from "../hooks/useDashboard";
|
} from "../hooks/useDashboard";
|
||||||
import { useWidgetInstances, useWidgetReferences } from "../hooks/useWidgets";
|
import { useDetachWidgetReference, 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 {
|
||||||
@@ -139,11 +139,15 @@ function MobileWidgetSections({
|
|||||||
{SECTION_META[section.id].label}
|
{SECTION_META[section.id].label}
|
||||||
</h3>
|
</h3>
|
||||||
{section.widgets.map((widget) => (
|
{section.widgets.map((widget) => (
|
||||||
<WidgetInstanceCard key={widget.id} widget={widget} onEdit={onEditWidget ? (id) => onEditWidget(id) : undefined} />
|
<WidgetInstanceCard
|
||||||
|
key={widget.id}
|
||||||
|
widget={widget}
|
||||||
|
onEdit={onEditWidget ? (id) => onEditWidget(id) : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -468,6 +472,13 @@ export function Dashboard() {
|
|||||||
.sort((a, b) => a.sort_order - b.sort_order);
|
.sort((a, b) => a.sort_order - b.sort_order);
|
||||||
}, [widgetInstances, widgetReferences]);
|
}, [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(
|
const mobileSections = useMemo(
|
||||||
() => groupWidgetsBySection(visibleWidgets, services),
|
() => groupWidgetsBySection(visibleWidgets, services),
|
||||||
[visibleWidgets, services],
|
[visibleWidgets, services],
|
||||||
@@ -567,10 +578,32 @@ export function Dashboard() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{isMobile && mobileSections.length > 0 ? (
|
{isMobile && mobileSections.length > 0 ? (
|
||||||
<MobileWidgetSections sections={mobileSections} onEditWidget={(id) => { setEditWidgetId(id); setWidgetDialogOpen(true); }} />
|
<MobileWidgetSections
|
||||||
|
sections={mobileSections}
|
||||||
|
onEditWidget={(id) => {
|
||||||
|
setEditWidgetId(id);
|
||||||
|
setWidgetDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
visibleWidgets.map((widget) => (
|
visibleWidgets.map((widget) => (
|
||||||
<WidgetInstanceCard key={widget.id} widget={widget} onEdit={(id) => { setEditWidgetId(id); setWidgetDialogOpen(true); }} />
|
<WidgetInstanceCard
|
||||||
|
key={widget.id}
|
||||||
|
widget={widget}
|
||||||
|
onEdit={(id) => {
|
||||||
|
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() {
|
|||||||
/>
|
/>
|
||||||
<WidgetConfigDialog
|
<WidgetConfigDialog
|
||||||
open={widgetDialogOpen}
|
open={widgetDialogOpen}
|
||||||
onClose={() => { setWidgetDialogOpen(false); setEditWidgetId(undefined); }}
|
onClose={() => {
|
||||||
|
setWidgetDialogOpen(false);
|
||||||
|
setEditWidgetId(undefined);
|
||||||
|
}}
|
||||||
dashboardScope="main"
|
dashboardScope="main"
|
||||||
editWidgetId={editWidgetId}
|
editWidgetId={editWidgetId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -97,7 +97,14 @@ export function NamedDashboardPage() {
|
|||||||
{visibleWidgets.length > 0 ? (
|
{visibleWidgets.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{visibleWidgets.map((widget) => (
|
{visibleWidgets.map((widget) => (
|
||||||
<WidgetInstanceCard key={widget.id} widget={widget} onEdit={(id) => { setEditWidgetId(id); setConfigOpen(true); }} />
|
<WidgetInstanceCard
|
||||||
|
key={widget.id}
|
||||||
|
widget={widget}
|
||||||
|
onEdit={(id) => {
|
||||||
|
setEditWidgetId(id);
|
||||||
|
setConfigOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -125,7 +132,10 @@ export function NamedDashboardPage() {
|
|||||||
|
|
||||||
<WidgetConfigDialog
|
<WidgetConfigDialog
|
||||||
open={configOpen}
|
open={configOpen}
|
||||||
onClose={() => { setConfigOpen(false); setEditWidgetId(undefined); }}
|
onClose={() => {
|
||||||
|
setConfigOpen(false);
|
||||||
|
setEditWidgetId(undefined);
|
||||||
|
}}
|
||||||
dashboardScope={dashboardScope}
|
dashboardScope={dashboardScope}
|
||||||
editWidgetId={editWidgetId}
|
editWidgetId={editWidgetId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ vi.mock("../../hooks/useSettings", () => ({
|
|||||||
vi.mock("../../hooks/useWidgets", () => ({
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
useWidgetInstances: () => ({ data: [] }),
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
useWidgetReferences: () => ({ data: [] }),
|
useWidgetReferences: () => ({ data: [] }),
|
||||||
|
useDetachWidgetReference: () => ({ mutate: () => {} }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../hooks/useServices", () => ({
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
useServiceInstances: () => ({ data: [] }),
|
useServiceInstances: () => ({ data: [] }),
|
||||||
|
|||||||
@@ -49,7 +49,14 @@ export function OverviewTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
{visibleWidgets.length > 0 ? (
|
{visibleWidgets.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{visibleWidgets.map((widget) => (
|
{visibleWidgets.map((widget) => (
|
||||||
<WidgetInstanceCard key={widget.id} widget={widget} onEdit={(id) => { setEditWidgetId(id); setConfigOpen(true); }} />
|
<WidgetInstanceCard
|
||||||
|
key={widget.id}
|
||||||
|
widget={widget}
|
||||||
|
onEdit={(id) => {
|
||||||
|
setEditWidgetId(id);
|
||||||
|
setConfigOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -72,7 +79,10 @@ export function OverviewTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
|
|
||||||
<WidgetConfigDialog
|
<WidgetConfigDialog
|
||||||
open={configOpen}
|
open={configOpen}
|
||||||
onClose={() => { setConfigOpen(false); setEditWidgetId(undefined); }}
|
onClose={() => {
|
||||||
|
setConfigOpen(false);
|
||||||
|
setEditWidgetId(undefined);
|
||||||
|
}}
|
||||||
serviceId={instance.id}
|
serviceId={instance.id}
|
||||||
editWidgetId={editWidgetId}
|
editWidgetId={editWidgetId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user