Fix: sheet scroll, direct-edit close, mobile copy btn, service badge

Four fixes:

1. Mobile edit fullscreen scroll: the Sheet primitive's
   data-[side=bottom]:h-auto was overriding our h-[100dvh] on
   SheetForm, preventing scroll. Added data-[side=bottom]:h-[100dvh]
   to the SheetForm className to win the specificity battle.

2. Direct-edit close showed list view: when opened via editWidgetId
   (the hover edit button), saving or canceling called reset() which
   showed the widget list instead of closing the dialog. Now derives
   directEdit from editWidgetId — when true, reset() calls onClose()
   to close entirely.

3. Copy button missing on mobile: MobileWidgetSections didn't pass
   onCopy to its WidgetInstanceCard instances. Now accepts and wires
   onCopyWidget, so referenced widgets show the copy/detach button on
   mobile too.

4. Service enabled badge stale: ServiceConfigEditor showed
   instance.enabled (the initial prop) instead of the local enabled
   state. Now reads the local enabled variable so the badge updates
   when the user toggles the switch.

128 tests pass; 0 lint errors; build clean.
This commit is contained in:
Developer
2026-07-06 14:51:14 +00:00
parent 04871bd7d4
commit 5a43894875
6 changed files with 148 additions and 130 deletions
@@ -228,6 +228,9 @@ export function WidgetConfigDialog({
const [draft, setDraft] = useState<Draft | null>(null);
const [draftBaseline, setDraftBaseline] = useState<Draft | null>(null);
// When opened via editWidgetId, closing the edit should close the dialog
// entirely (not fall back to the list view).
const directEdit = Boolean(editWidgetId);
// When editWidgetId is set and the dialog opens, auto-enter edit mode for
// that widget (instead of showing the list view).
@@ -281,6 +284,12 @@ export function WidgetConfigDialog({
}
function reset() {
// If we were opened via direct edit, closing should close the dialog
// entirely, not fall back to the list view.
if (directEdit) {
onClose();
return;
}
setDraft(null);
setDraftBaseline(null);
}
+2 -2
View File
@@ -52,7 +52,7 @@ export function WidgetInstanceCard({ widget, onEdit, onCopy }: Props) {
: `Unknown widget: ${widget.widget_kind} (built-in)`;
return (
<div className="group relative">
{(onEdit || onCopy) ? actionButtons : null}
{onEdit || onCopy ? actionButtons : null}
<SectionCard title={widget.title}>
<Alert>
<AlertDescription>{label}</AlertDescription>
@@ -65,7 +65,7 @@ export function WidgetInstanceCard({ widget, onEdit, onCopy }: Props) {
const Component = resolved.component;
return (
<div className="group relative">
{(onEdit || onCopy) ? actionButtons : null}
{onEdit || onCopy ? actionButtons : null}
<Component
widget={widget}
refreshIntervalMs={resolved.refreshIntervalMs}
+118 -122
View File
@@ -1,166 +1,162 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"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
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"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,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"text-base leading-none font-medium",
className
)}
{...props}
/>
)
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+1 -1
View File
@@ -82,7 +82,7 @@ export function SheetForm({
<SheetContent
side="bottom"
showCloseButton={false}
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-full"
className="flex h-[100dvh] w-full flex-col gap-0 p-0 data-[side=bottom]:h-[100dvh] sm:max-w-full"
onEscapeKeyDown={(e) => {
// Prevent Radix's default Escape close so our guard runs instead.
if (isDirty) {
+16 -3
View File
@@ -31,7 +31,11 @@ import {
useDeleteDashboardShortcut,
useSaveDashboardShortcut,
} from "../hooks/useDashboard";
import { useDetachWidgetReference, useWidgetInstances, useWidgetReferences } from "../hooks/useWidgets";
import {
useDetachWidgetReference,
useWidgetInstances,
useWidgetReferences,
} from "../hooks/useWidgets";
import { useServiceInstances } from "../hooks/useServices";
import { useIsMobile } from "../hooks/useIsMobile";
import type {
@@ -98,9 +102,11 @@ function groupWidgetsBySection(
function MobileWidgetSections({
sections,
onEditWidget,
onCopyWidget,
}: {
sections: { id: SectionId; widgets: WidgetInstance[] }[];
onEditWidget?: (widgetId: string) => void;
onCopyWidget?: (widgetId: string) => void;
}) {
return (
<>
@@ -143,6 +149,7 @@ function MobileWidgetSections({
key={widget.id}
widget={widget}
onEdit={onEditWidget ? (id) => onEditWidget(id) : undefined}
onCopy={onCopyWidget ? (id) => onCopyWidget(id) : undefined}
/>
))}
</section>
@@ -584,6 +591,10 @@ export function Dashboard() {
setEditWidgetId(id);
setWidgetDialogOpen(true);
}}
onCopyWidget={(id) => {
const ref = widgetReferences.find((r) => r.widget.id === id);
if (ref) detachRef.mutate(ref.id);
}}
/>
) : (
visibleWidgets.map((widget) => (
@@ -598,10 +609,12 @@ export function Dashboard() {
referencedWidgetIds.has(widget.id)
? () => {
// Detach: find the reference and clone it.
const ref = widgetReferences.find((r) => r.widget.id === widget.id);
const ref = widgetReferences.find(
(r) => r.widget.id === widget.id,
);
if (ref) detachRef.mutate(ref.id);
}
: undefined
: undefined
}
/>
))
+2 -2
View File
@@ -1486,8 +1486,8 @@ function ServiceConfigEditor({
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium">{instance.name}</span>
<Badge variant={instance.enabled ? "default" : "secondary"}>
{instance.enabled ? "enabled" : "disabled"}
<Badge variant={enabled ? "default" : "secondary"}>
{enabled ? "enabled" : "disabled"}
</Badge>
</div>
<div className="flex flex-col gap-3">