fix(dashboard): fix group/widget state loss due to partial preference saves and stale closures

This commit is contained in:
Matthias Hochmeister
2026-04-13 15:28:20 +02:00
parent b275d4baa5
commit a0b3c0ec5c

View File

@@ -161,6 +161,14 @@ function Dashboard() {
// Widget order from preferences, falling back to defaults
const [localOrder, setLocalOrder] = useState<Record<string, string[]>>(DEFAULT_ORDER);
const [localCustomGroups, setLocalCustomGroups] = useState<{ name: string; title: string }[]>([]);
// Always persist the FULL preferences object to avoid wiping unrelated fields (widgets, menuOrder, etc.)
const savePreferences = useCallback((patch: Record<string, unknown>) => {
const full = { ...(preferences ?? {}), ...patch };
queryClient.setQueryData(['user-preferences'], full);
preferencesApi.update(full);
}, [preferences, queryClient]);
useEffect(() => {
if (preferences?.widgetOrder) {
@@ -188,6 +196,9 @@ function Dashboard() {
return merged;
});
// Sync local custom groups
setLocalCustomGroups((preferences.customGroups as { name: string; title: string }[]) ?? []);
// Sync group order from preferences
const customGroupNames = (preferences.customGroups as { name: string; title: string }[] | undefined ?? []).map(g => g.name);
const allGroupNames = [...BUILTIN_GROUPS.map(g => g.name), ...customGroupNames];
@@ -289,9 +300,7 @@ function Dashboard() {
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(localGroupOrder, oldIndex, newIndex);
setLocalGroupOrder(newOrder);
preferencesApi.update({ widgetOrder: localOrder, groupsOrder: newOrder }).then(() => {
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
});
savePreferences({ widgetOrder: localOrder, groupsOrder: newOrder });
return;
}
@@ -318,18 +327,14 @@ function Dashboard() {
}
}
// Cross-group move was already handled in handleDragOver
// Persist current state
// Use a timeout to read the latest localOrder after state updates
// Persist current state using a timeout to read the latest localOrder after state updates
setTimeout(() => {
setLocalOrder((current) => {
preferencesApi.update({ widgetOrder: current }).then(() => {
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
});
savePreferences({ widgetOrder: current });
return current;
});
}, 0);
}, [localOrder, localGroupOrder, queryClient, findGroupForWidget]);
}, [localOrder, localGroupOrder, savePreferences, findGroupForWidget]);
// ── Add custom group ──
const handleAddGroup = useCallback(() => {
@@ -338,20 +343,19 @@ function Dashboard() {
const name = title.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_');
// Guard duplicate
const existingGroups = [...BUILTIN_GROUPS, ...((preferences?.customGroups as { name: string; title: string }[]) || [])];
const existingGroups = [...BUILTIN_GROUPS, ...localCustomGroups];
if (existingGroups.some((g) => g.name === name)) return;
const updatedCustomGroups = [...((preferences?.customGroups as { name: string; title: string }[]) || []), { name, title }];
const updatedCustomGroups = [...localCustomGroups, { name, title }];
const updatedOrder = { ...localOrder, [name]: [] };
const updatedGroupOrder = [...localGroupOrder, name];
setLocalOrder(updatedOrder);
setLocalGroupOrder(updatedGroupOrder);
preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups, groupsOrder: updatedGroupOrder }).then(() => {
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
});
setLocalCustomGroups(updatedCustomGroups);
savePreferences({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups, groupsOrder: updatedGroupOrder });
setNewGroupTitle('');
setAddGroupOpen(false);
}, [newGroupTitle, preferences?.customGroups, localOrder, queryClient]);
}, [newGroupTitle, localCustomGroups, localOrder, localGroupOrder, savePreferences]);
// ── Delete custom group — move widgets back to their default groups ──
const handleDeleteGroup = useCallback((groupName: string) => {
@@ -369,25 +373,23 @@ function Dashboard() {
updatedOrder[targetGroup].push(widgetKey);
}
const updatedCustomGroups = ((preferences?.customGroups as { name: string; title: string }[]) || []).filter((g) => g.name !== groupName);
const updatedCustomGroups = localCustomGroups.filter((g) => g.name !== groupName);
const updatedGroupOrder = localGroupOrder.filter(n => n !== groupName);
setLocalOrder(updatedOrder);
setLocalGroupOrder(updatedGroupOrder);
preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups, groupsOrder: updatedGroupOrder }).then(() => {
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
});
}, [localOrder, localGroupOrder, preferences?.customGroups, queryClient]);
setLocalCustomGroups(updatedCustomGroups);
savePreferences({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups, groupsOrder: updatedGroupOrder });
}, [localOrder, localGroupOrder, localCustomGroups, savePreferences]);
// ── Reset to defaults ──
const handleReset = useCallback(() => {
const defaultGroupOrder = BUILTIN_GROUPS.map(g => g.name);
setLocalOrder(DEFAULT_ORDER);
setLocalGroupOrder(defaultGroupOrder);
preferencesApi.update({ widgetOrder: DEFAULT_ORDER, customGroups: [], groupsOrder: defaultGroupOrder }).then(() => {
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
});
setLocalCustomGroups([]);
savePreferences({ widgetOrder: DEFAULT_ORDER, customGroups: [], groupsOrder: defaultGroupOrder });
setResetOpen(false);
}, [queryClient]);
}, [savePreferences]);
useEffect(() => {
const timer = setTimeout(() => {
@@ -463,20 +465,13 @@ function Dashboard() {
// Compute the full group list: built-in + custom groups, ordered by localGroupOrder
const groupOrder = useMemo(() => {
const allGroups = [...BUILTIN_GROUPS];
if (preferences?.customGroups) {
for (const cg of preferences.customGroups as { name: string; title: string }[]) {
if (!allGroups.some((g) => g.name === cg.name)) {
allGroups.push(cg);
}
}
}
const allGroups = [...BUILTIN_GROUPS, ...localCustomGroups.filter(cg => !BUILTIN_GROUPS.some(g => g.name === cg.name))];
const orderedGroups = localGroupOrder
.map(name => allGroups.find(g => g.name === name))
.filter((g): g is { name: string; title: string } => !!g);
const remaining = allGroups.filter(g => !localGroupOrder.includes(g.name));
return [...orderedGroups, ...remaining];
}, [preferences?.customGroups, localGroupOrder]);
}, [localCustomGroups, localGroupOrder]);
return (
<DashboardLayout>