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:
Developer
2026-07-06 12:11:47 +00:00
parent c36262d7b6
commit 1e636fdbe2
9 changed files with 294 additions and 70 deletions
+49 -8
View File
@@ -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>
);
}
@@ -1,21 +1,39 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NamedDashboardPage } from "../NamedDashboardPage";
vi.mock("../../hooks/useDashboards", () => ({
useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })),
}));
vi.mock("../../hooks/useWidgets", () => ({
useWidgetReferences: () => ({ data: [] }),
}));
vi.mock("../../components/WidgetConfigDialog", () => ({
WidgetConfigDialog: () => <div data-testid="config-dialog-stub" />,
}));
vi.mock("../../components/WidgetInstance", () => ({
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
}));
import { useDashboardBySlug } from "../../hooks/useDashboards";
function renderPage(slug: string) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<MemoryRouter initialEntries={[`/d/${slug}`]}>
<Routes>
<Route path="/d/:slug" element={<NamedDashboardPage />} />
</Routes>
</MemoryRouter>,
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/d/${slug}`]}>
<Routes>
<Route path="/d/:slug" element={<NamedDashboardPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
@@ -88,6 +106,26 @@ describe("NamedDashboardPage", () => {
} as never);
renderPage("empty");
expect(screen.getByText("Empty")).toBeInTheDocument();
expect(screen.getByText(/no shortcuts yet/i)).toBeInTheDocument();
expect(screen.getByText(/This dashboard is empty/i)).toBeInTheDocument();
});
it("renders an edit-widgets button", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: {
id: "d1",
label: "Storage",
slug: "storage",
sort_order: 0,
payload: { items: [] },
created_at: 1,
updated_at: 1,
},
isLoading: false,
isError: false,
} as never);
renderPage("storage");
expect(
screen.getByRole("button", { name: /Edit widgets/i }),
).toBeInTheDocument();
});
});