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