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
@@ -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();
});
});