feat(FN-2269): migrate dashboard session selection to global settings

- Add global settings schema/types fields for persisted dashboard session state (selected project and node)
- Refactor NodeContext and current-project hooks to read/write project and node selection via global settings instead of project-local state
- Update App wiring to use the new selection flow across dashboard startup and switching behavior
- Add and expand dashboard tests for NodeContext, useCurrentProject, and view-state persistence behavior
This commit is contained in:
Fusion
2026-04-22 17:12:27 -07:00
committed by gsxdsm
parent 331d283125
commit 85a1adbd0b
9 changed files with 602 additions and 118 deletions

View File

@@ -1,12 +1,15 @@
/**
* NodeContext provides React context for tracking which node the dashboard is currently viewing.
* This enables seamless routing of API calls through the proxy when viewing remote nodes.
* Persists the selected node ID to global settings (server-backed) instead of localStorage.
*/
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import type { NodeConfig } from "@fusion/core";
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
const STORAGE_KEY = "fusion-dashboard-current-node";
// Legacy localStorage key for migration - no longer used as primary storage
const LEGACY_STORAGE_KEY = "fusion-dashboard-current-node";
export interface NodeContextValue {
/** Currently selected node or null if viewing local node */
@@ -29,43 +32,93 @@ export interface NodeProviderProps {
/**
* Provider component that manages the current node state.
* Persists the selected nodeId to localStorage and derives isRemote from node type.
* Persists the selected nodeId to global settings and derives isRemote from node type.
* This enables PWA fresh sessions to restore the correct node context.
*/
export function NodeProvider({ children }: NodeProviderProps) {
const [currentNode, setCurrentNodeState] = useState<NodeConfig | null>(null);
const [loading, setLoading] = useState(true);
// Track if we've hydrated from global settings
const hydratedRef = useRef(false);
// Cache of current settings to avoid repeated fetches
const settingsCacheRef = useRef<string | undefined>(undefined);
// Load from localStorage on mount
// Load from global settings on mount
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as NodeConfig;
// Only restore if it's a remote node
if (parsed && parsed.type === "remote") {
setCurrentNodeState(parsed);
let cancelled = false;
async function loadFromGlobalSettings() {
try {
const settings = await fetchGlobalSettings();
if (cancelled) return;
// Cache the current node ID
settingsCacheRef.current = settings.dashboardCurrentNodeId;
const savedNodeId = settings.dashboardCurrentNodeId;
if (savedNodeId) {
// We don't have the full NodeConfig from settings, just the ID
// The App.tsx will resolve the full node from the nodes list
// We just need to indicate that a remote node is selected
hydratedRef.current = true;
}
// Also migrate legacy localStorage if no global settings entry exists
if (!savedNodeId) {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
const parsed = JSON.parse(legacy) as NodeConfig;
if (parsed?.id && parsed?.type === "remote") {
// Migrate to global settings
settingsCacheRef.current = parsed.id;
await updateGlobalSettings({ dashboardCurrentNodeId: parsed.id }).catch(() => {
// Non-critical - migration failed
});
}
}
} catch {
// Ignore legacy localStorage errors
}
}
} catch {
// Global settings fetch failed - non-critical
} finally {
if (!cancelled) {
setLoading(false);
}
}
} catch {
// Ignore localStorage errors
}
loadFromGlobalSettings();
return () => {
cancelled = true;
};
}, []);
// Persist remote node to localStorage
// Sync remote node selection to global settings
useEffect(() => {
if (currentNode && currentNode.type === "remote") {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentNode));
} catch {
// Ignore localStorage errors
}
} else {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore localStorage errors
if (loading) return;
// Only persist if we've hydrated and have a remote node
if (currentNode && currentNode.type === "remote" && currentNode.id) {
const newId = currentNode.id;
if (settingsCacheRef.current !== newId) {
settingsCacheRef.current = newId;
updateGlobalSettings({ dashboardCurrentNodeId: newId }).catch(() => {
// Non-critical - persistence failed
});
}
} else if (currentNode === null && settingsCacheRef.current !== undefined) {
// Clear the node selection
settingsCacheRef.current = undefined;
updateGlobalSettings({ dashboardCurrentNodeId: undefined }).catch(() => {
// Non-critical - persistence failed
});
}
}, [currentNode]);
}, [currentNode, loading]);
const setCurrentNode = useCallback((node: NodeConfig | null) => {
setCurrentNodeState(node);
@@ -73,11 +126,11 @@ export function NodeProvider({ children }: NodeProviderProps) {
const clearCurrentNode = useCallback(() => {
setCurrentNodeState(null);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore localStorage errors
}
// Clear from cache immediately for responsiveness
settingsCacheRef.current = undefined;
updateGlobalSettings({ dashboardCurrentNodeId: undefined }).catch(() => {
// Non-critical - persistence failed
});
}, []);
const value: NodeContextValue = {