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
+10
View File
@@ -87,3 +87,13 @@ export async function detachWidgetReference(
): Promise<WidgetInstance> {
return post<WidgetInstance>(`/api/widgets/references/${referenceId}/detach`);
}
export async function updateWidgetReference(
referenceId: string,
sortOrder: number,
): Promise<WidgetReference> {
return put<WidgetReference>(
`/api/widgets/references/${referenceId}?sort_order=${sortOrder}`,
{},
);
}
+37 -28
View File
@@ -34,6 +34,7 @@ import {
useDeleteWidgetReference,
useDetachWidgetReference,
useSaveWidgetInstance,
useUpdateWidgetReference,
useWidgetInstances,
useWidgetReferences,
} from "../hooks/useWidgets";
@@ -212,20 +213,13 @@ export function WidgetConfigDialog({
const createRef = useCreateWidgetReference();
const deleteRef = useDeleteWidgetReference();
const detachRef = useDetachWidgetReference();
const updateRef = useUpdateWidgetReference();
const { data: allWidgets = [] } = useWidgetInstances();
const [showExisting, setShowExisting] = useState(false);
const [existingSearch, setExistingSearch] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const sortedInstances = useMemo(
() =>
[...instances].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
),
[instances],
);
function startAddBuiltIn(kind: string) {
const binding = BUILTIN_WIDGETS[kind];
setDraft({
@@ -297,13 +291,29 @@ export function WidgetConfigDialog({
async function moveInstance(index: number, direction: -1 | 1) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return;
const a = sortedInstances[index];
const b = sortedInstances[targetIndex];
// Sequential (not Promise.all) to avoid a race where the first mutation's
// cache invalidation refetches before the second completes, reverting the swap.
await saveWidget.mutateAsync({ ...a, sort_order: b.sort_order });
await saveWidget.mutateAsync({ ...b, sort_order: a.sort_order });
if (targetIndex < 0 || targetIndex >= combinedWidgets.length) return;
const a = combinedWidgets[index];
const b = combinedWidgets[targetIndex];
const aRefId = (a as { _ref_id?: string })._ref_id;
const bRefId = (b as { _ref_id?: string })._ref_id;
// References use their own sort_order on the widget_references row;
// owned widgets use the widget instance's sort_order.
if (aRefId) {
await updateRef.mutateAsync({
referenceId: aRefId,
sortOrder: b.sort_order,
});
} else {
await saveWidget.mutateAsync({ ...a, sort_order: b.sort_order });
}
if (bRefId) {
await updateRef.mutateAsync({
referenceId: bRefId,
sortOrder: a.sort_order,
});
} else {
await saveWidget.mutateAsync({ ...b, sort_order: a.sort_order });
}
}
async function removeInstance(instance: WidgetInstance) {
@@ -311,20 +321,19 @@ export function WidgetConfigDialog({
}
// Build a combined view of owned widgets + references for display.
const owned = [...instances].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
);
const refs = references.map((r) => ({
...r.widget,
_ref_id: r.id,
_is_reference: true as const,
}));
const combinedWidgets = [...owned, ...refs].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
);
const referencedWidgetIds = new Set(references.map((r) => r.widget_id));
const combinedWidgets = useMemo(() => {
const owned = [...instances].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
);
const refs = references.map((r) => ({
...r.widget,
_ref_id: r.id,
_is_reference: true as const,
}));
return [...owned, ...refs].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
);
}, [instances, references]);
// Available widgets for the "Add existing" picker: all widgets not already
// on this dashboard (owned or referenced).
@@ -24,6 +24,7 @@ vi.mock("../../hooks/useWidgets", () => ({
useCreateWidgetReference: () => ({ mutateAsync: vi.fn() }),
useDeleteWidgetReference: () => ({ mutateAsync: vi.fn() }),
useDetachWidgetReference: () => ({ mutateAsync: vi.fn() }),
useUpdateWidgetReference: () => ({ mutateAsync: vi.fn() }),
}));
vi.mock("../../hooks/useServices", () => ({
+17
View File
@@ -10,6 +10,7 @@ import {
fetchWidgetInstances,
fetchWidgetReferences,
updateWidgetInstance,
updateWidgetReference,
} from "../api/widgets";
import type { WidgetInstanceInput } from "../types";
@@ -101,3 +102,19 @@ export function useDetachWidgetReference() {
},
});
}
export function useUpdateWidgetReference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
referenceId,
sortOrder,
}: {
referenceId: string;
sortOrder: number;
}) => updateWidgetReference(referenceId, sortOrder),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets", "references"] });
},
});
}
+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();
});
});