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:
@@ -228,6 +228,9 @@ export function WidgetConfigDialog({
|
|||||||
|
|
||||||
const [draft, setDraft] = useState<Draft | null>(null);
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
const [draftBaseline, setDraftBaseline] = 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
|
// 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).
|
||||||
@@ -281,6 +284,12 @@ export function WidgetConfigDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
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);
|
setDraft(null);
|
||||||
setDraftBaseline(null);
|
setDraftBaseline(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function WidgetInstanceCard({ widget, onEdit, onCopy }: Props) {
|
|||||||
: `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 || onCopy) ? actionButtons : null}
|
{onEdit || onCopy ? actionButtons : null}
|
||||||
<SectionCard title={widget.title}>
|
<SectionCard title={widget.title}>
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>{label}</AlertDescription>
|
<AlertDescription>{label}</AlertDescription>
|
||||||
@@ -65,7 +65,7 @@ export function WidgetInstanceCard({ widget, onEdit, onCopy }: Props) {
|
|||||||
const Component = resolved.component;
|
const Component = resolved.component;
|
||||||
return (
|
return (
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
{(onEdit || onCopy) ? actionButtons : null}
|
{onEdit || onCopy ? actionButtons : null}
|
||||||
<Component
|
<Component
|
||||||
widget={widget}
|
widget={widget}
|
||||||
refreshIntervalMs={resolved.refreshIntervalMs}
|
refreshIntervalMs={resolved.refreshIntervalMs}
|
||||||
|
|||||||
@@ -1,166 +1,162 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
function DialogTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({
|
function DialogPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
function DialogClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<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)] 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",
|
"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}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="absolute top-2 right-2"
|
className="absolute top-2 right-2"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon />
|
||||||
/>
|
<span className="sr-only">Close</span>
|
||||||
<span className="sr-only">Close</span>
|
</Button>
|
||||||
</Button>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Close>
|
)}
|
||||||
)}
|
</DialogPrimitive.Content>
|
||||||
</DialogPrimitive.Content>
|
</DialogPortal>
|
||||||
</DialogPortal>
|
);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-header"
|
data-slot="dialog-header"
|
||||||
className={cn("flex flex-col gap-2", className)}
|
className={cn("flex flex-col gap-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({
|
function DialogFooter({
|
||||||
className,
|
className,
|
||||||
showCloseButton = false,
|
showCloseButton = false,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
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",
|
"-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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close asChild>
|
<DialogPrimitive.Close asChild>
|
||||||
<Button variant="outline">Close</Button>
|
<Button variant="outline">Close</Button>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({
|
function DialogTitle({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn(
|
className={cn("text-base leading-none font-medium", className)}
|
||||||
"text-base leading-none font-medium",
|
{...props}
|
||||||
className
|
/>
|
||||||
)}
|
);
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function SheetForm({
|
|||||||
<SheetContent
|
<SheetContent
|
||||||
side="bottom"
|
side="bottom"
|
||||||
showCloseButton={false}
|
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) => {
|
onEscapeKeyDown={(e) => {
|
||||||
// Prevent Radix's default Escape close so our guard runs instead.
|
// Prevent Radix's default Escape close so our guard runs instead.
|
||||||
if (isDirty) {
|
if (isDirty) {
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ import {
|
|||||||
useDeleteDashboardShortcut,
|
useDeleteDashboardShortcut,
|
||||||
useSaveDashboardShortcut,
|
useSaveDashboardShortcut,
|
||||||
} from "../hooks/useDashboard";
|
} from "../hooks/useDashboard";
|
||||||
import { useDetachWidgetReference, 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 {
|
||||||
@@ -98,9 +102,11 @@ function groupWidgetsBySection(
|
|||||||
function MobileWidgetSections({
|
function MobileWidgetSections({
|
||||||
sections,
|
sections,
|
||||||
onEditWidget,
|
onEditWidget,
|
||||||
|
onCopyWidget,
|
||||||
}: {
|
}: {
|
||||||
sections: { id: SectionId; widgets: WidgetInstance[] }[];
|
sections: { id: SectionId; widgets: WidgetInstance[] }[];
|
||||||
onEditWidget?: (widgetId: string) => void;
|
onEditWidget?: (widgetId: string) => void;
|
||||||
|
onCopyWidget?: (widgetId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -143,6 +149,7 @@ function MobileWidgetSections({
|
|||||||
key={widget.id}
|
key={widget.id}
|
||||||
widget={widget}
|
widget={widget}
|
||||||
onEdit={onEditWidget ? (id) => onEditWidget(id) : undefined}
|
onEdit={onEditWidget ? (id) => onEditWidget(id) : undefined}
|
||||||
|
onCopy={onCopyWidget ? (id) => onCopyWidget(id) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
@@ -584,6 +591,10 @@ export function Dashboard() {
|
|||||||
setEditWidgetId(id);
|
setEditWidgetId(id);
|
||||||
setWidgetDialogOpen(true);
|
setWidgetDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
|
onCopyWidget={(id) => {
|
||||||
|
const ref = widgetReferences.find((r) => r.widget.id === id);
|
||||||
|
if (ref) detachRef.mutate(ref.id);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
visibleWidgets.map((widget) => (
|
visibleWidgets.map((widget) => (
|
||||||
@@ -598,10 +609,12 @@ export function Dashboard() {
|
|||||||
referencedWidgetIds.has(widget.id)
|
referencedWidgetIds.has(widget.id)
|
||||||
? () => {
|
? () => {
|
||||||
// Detach: find the reference and clone it.
|
// 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);
|
if (ref) detachRef.mutate(ref.id);
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1486,8 +1486,8 @@ function ServiceConfigEditor({
|
|||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<span className="font-medium">{instance.name}</span>
|
<span className="font-medium">{instance.name}</span>
|
||||||
<Badge variant={instance.enabled ? "default" : "secondary"}>
|
<Badge variant={enabled ? "default" : "secondary"}>
|
||||||
{instance.enabled ? "enabled" : "disabled"}
|
{enabled ? "enabled" : "disabled"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user