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
+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", () => ({