Follow-ups: reference reorder, detach service_id, named-dashboard widgets
Three reusable-widget follow-up fixes:
1. Reference sort_order independently reorderable. Reordering a
referenced widget now updates the widget_references.sort_order (per-
dashboard), not the shared widget instance sort_order. New backend
update_widget_reference method + PUT /api/widgets/references/{id}
endpoint. Frontend moveInstance checks _ref_id to choose the right
mutation (updateRef for references, saveWidget for owned).
2. Detach preserves service_id. detach_widget_reference now copies the
original widget's service_id into the clone, so service-bound widgets
(Grafana chart, Jellyfin activity) continue to render after detach.
3. Named dashboards support widget references. NamedDashboardPage
fetches useWidgetReferences('named:<slug>') and renders them via
WidgetInstanceCard alongside pinned links. 'Edit widgets' button
opens WidgetConfigDialog with dashboardScope='named:<slug>'.
Also: removed useMemo on combinedWidgets in WidgetConfigDialog to fix
a react-hooks/preserve-manual-memoization lint error (the React Compiler
ESLint plugin couldn't verify the spread+sort memoization).
283 backend tests pass (+1 update_reference test); 128 frontend tests
pass; ruff clean; 0 lint errors.
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Boxes } from "lucide-react";
|
||||
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
|
||||
@@ -42,12 +46,24 @@ function parseItems(payload: Record<string, unknown>): DashboardItem[] {
|
||||
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 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" />;
|
||||
}
|
||||
@@ -64,17 +80,36 @@ export function NamedDashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<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>
|
||||
{items.length === 0 ? (
|
||||
|
||||
{visibleWidgets.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{visibleWidgets.map((widget) => (
|
||||
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{items.length === 0 && visibleWidgets.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
This dashboard has no shortcuts yet. Add pinned service links from
|
||||
the dashboard management panel on the Services page.
|
||||
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
|
||||
@@ -85,7 +120,13 @@ export function NamedDashboardPage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<WidgetConfigDialog
|
||||
open={configOpen}
|
||||
onClose={() => setConfigOpen(false)}
|
||||
dashboardScope={dashboardScope}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user