Files
manage/frontend/src/pages/NamedDashboardPage.tsx
T
Developer 04871bd7d4 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.
2026-07-06 14:26:58 +00:00

145 lines
3.9 KiB
TypeScript

import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Boxes, Settings2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useDashboardBySlug } from "../hooks/useDashboards";
import { useWidgetReferences } from "../hooks/useWidgets";
import { PinnedServiceLink } from "../components/PinnedServiceLink";
import { WidgetInstanceCard } from "../components/WidgetInstance";
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
/**
* Payload model for named dashboards (design choice: inline items, not widget
* instance ids). The payload stores an ordered list of items:
*
* ```
* { items: DashboardItem[] }
* ```
*
* Where `DashboardItem` is either a pinned service link (this slice) or a
* future widget reference (follow-up). Widget composition on named dashboards
* is deferred — the main Dashboard already has the rich widget config dialog.
*/
interface LinkItem {
type: "link";
label: string;
target: string;
}
type DashboardItem = LinkItem;
function parseItems(payload: Record<string, unknown>): DashboardItem[] {
const items = payload.items;
if (!Array.isArray(items)) return [];
return items.filter(
(item): item is LinkItem =>
typeof item === "object" &&
item !== null &&
item.type === "link" &&
typeof item.label === "string" &&
typeof item.target === "string",
);
}
export function NamedDashboardPage() {
const { slug = "" } = useParams<{ slug: string }>();
const { data: dashboard, isLoading, isError } = useDashboardBySlug(slug);
const dashboardScope = `named:${slug}`;
const { data: widgetRefs = [] } = useWidgetReferences(dashboardScope);
const [configOpen, setConfigOpen] = useState(false);
const [editWidgetId, setEditWidgetId] = useState<string | undefined>();
const items = useMemo(
() => parseItems(dashboard?.payload ?? {}),
[dashboard?.payload],
);
const visibleWidgets = useMemo(
() =>
widgetRefs
.filter((r) => r.widget.enabled)
.map((r) => r.widget)
.sort((a, b) => a.sort_order - b.sort_order),
[widgetRefs],
);
if (isLoading) {
return <Skeleton className="h-32 w-full" />;
}
if (isError || !dashboard) {
return (
<Alert>
<AlertDescription>
Dashboard not found. It may have been deleted or the link is invalid.
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{dashboard.label}</h2>
<Button
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => setConfigOpen(true)}
>
<Settings2 className="size-4" />
Edit widgets
</Button>
</div>
{visibleWidgets.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{visibleWidgets.map((widget) => (
<WidgetInstanceCard
key={widget.id}
widget={widget}
onEdit={(id) => {
setEditWidgetId(id);
setConfigOpen(true);
}}
/>
))}
</div>
) : null}
{items.length === 0 && visibleWidgets.length === 0 ? (
<Alert>
<AlertDescription>
This dashboard is empty. Add widgets via "Edit widgets" or pinned
service links from the dashboard management panel on the Services
page.
</AlertDescription>
</Alert>
) : items.length > 0 ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((item, index) => (
<PinnedServiceLink
key={`${item.target}-${index}`}
label={item.label}
target={item.target}
icon={Boxes}
/>
))}
</div>
) : null}
<WidgetConfigDialog
open={configOpen}
onClose={() => {
setConfigOpen(false);
setEditWidgetId(undefined);
}}
dashboardScope={dashboardScope}
editWidgetId={editWidgetId}
/>
</div>
);
}