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:
@@ -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", () => ({
|
||||
|
||||
Reference in New Issue
Block a user