04871bd7d4
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.
145 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|