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:
@@ -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 = {
|
||||
|
||||
206
packages/dashboard/app/context/__tests__/NodeContext.test.tsx
Normal file
206
packages/dashboard/app/context/__tests__/NodeContext.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { NodeProvider, useNodeContext } from "../NodeContext";
|
||||
import type { NodeConfig } from "@fusion/core";
|
||||
|
||||
// Mock the API functions
|
||||
vi.mock("../../api", () => ({
|
||||
fetchGlobalSettings: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchGlobalSettings, updateGlobalSettings } from "../../api";
|
||||
|
||||
const mockRemoteNode: NodeConfig = {
|
||||
id: "node-remote-1",
|
||||
name: "Remote Node",
|
||||
type: "remote",
|
||||
url: "https://remote.example.com",
|
||||
apiKey: "test-key",
|
||||
};
|
||||
|
||||
describe("NodeContext", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
// Default mock implementations
|
||||
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
(updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function renderWithProvider() {
|
||||
return renderHook(() => useNodeContext(), {
|
||||
wrapper: ({ children }) => <NodeProvider>{children}</NodeProvider>,
|
||||
});
|
||||
}
|
||||
|
||||
it("initializes with null currentNode when no saved node", async () => {
|
||||
const { result } = renderWithProvider();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentNode).toBeNull();
|
||||
});
|
||||
|
||||
expect(result.current.currentNodeId).toBeNull();
|
||||
expect(result.current.isRemote).toBe(false);
|
||||
});
|
||||
|
||||
it("loads saved node ID from global settings", async () => {
|
||||
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
dashboardCurrentNodeId: "node-remote-1",
|
||||
});
|
||||
|
||||
const { result } = renderWithProvider();
|
||||
|
||||
// Should have loaded the node ID from global settings
|
||||
// Note: currentNode stays null until App.tsx resolves it from nodes list
|
||||
await waitFor(() => {
|
||||
expect(fetchGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The NodeContext only stores the ID; App.tsx resolves the full node
|
||||
// We verify the settings were fetched correctly
|
||||
expect(result.current.currentNode).toBeNull();
|
||||
});
|
||||
|
||||
it("persists node selection to global settings", async () => {
|
||||
const { result } = renderWithProvider();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentNode).toBeNull();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentNode(mockRemoteNode);
|
||||
});
|
||||
|
||||
expect(result.current.currentNode).toEqual(mockRemoteNode);
|
||||
expect(result.current.currentNodeId).toBe("node-remote-1");
|
||||
expect(result.current.isRemote).toBe(true);
|
||||
|
||||
// Should persist to global settings
|
||||
expect(updateGlobalSettings).toHaveBeenCalledWith({
|
||||
dashboardCurrentNodeId: "node-remote-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears node selection and updates global settings", async () => {
|
||||
// Start with a saved node
|
||||
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
dashboardCurrentNodeId: "node-remote-1",
|
||||
});
|
||||
|
||||
const { result } = renderWithProvider();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Simulate App.tsx resolving the node from nodes list
|
||||
act(() => {
|
||||
result.current.setCurrentNode(mockRemoteNode);
|
||||
});
|
||||
|
||||
expect(result.current.currentNode).toEqual(mockRemoteNode);
|
||||
|
||||
// Clear the selection
|
||||
act(() => {
|
||||
result.current.clearCurrentNode();
|
||||
});
|
||||
|
||||
expect(result.current.currentNode).toBeNull();
|
||||
expect(result.current.currentNodeId).toBeNull();
|
||||
expect(result.current.isRemote).toBe(false);
|
||||
|
||||
// Should persist the clear to global settings
|
||||
expect(updateGlobalSettings).toHaveBeenLastCalledWith({
|
||||
dashboardCurrentNodeId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles global settings fetch failure gracefully", async () => {
|
||||
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { result } = renderWithProvider();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentNode).toBeNull();
|
||||
});
|
||||
|
||||
// Should still be usable even when settings fetch fails
|
||||
act(() => {
|
||||
result.current.setCurrentNode(mockRemoteNode);
|
||||
});
|
||||
|
||||
expect(result.current.currentNode).toEqual(mockRemoteNode);
|
||||
expect(result.current.isRemote).toBe(true);
|
||||
});
|
||||
|
||||
it("handles global settings update failure gracefully", async () => {
|
||||
(updateGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { result } = renderWithProvider();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentNode).toBeNull();
|
||||
});
|
||||
|
||||
// Should still update state even if persistence fails
|
||||
act(() => {
|
||||
result.current.setCurrentNode(mockRemoteNode);
|
||||
});
|
||||
|
||||
expect(result.current.currentNode).toEqual(mockRemoteNode);
|
||||
});
|
||||
|
||||
it("migrates legacy localStorage to global settings", async () => {
|
||||
// Set up legacy localStorage
|
||||
localStorage.setItem("fusion-dashboard-current-node", JSON.stringify(mockRemoteNode));
|
||||
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
|
||||
const { result } = renderWithProvider();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should migrate to global settings
|
||||
expect(updateGlobalSettings).toHaveBeenCalledWith({
|
||||
dashboardCurrentNodeId: "node-remote-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not migrate local node from legacy localStorage", async () => {
|
||||
// localStorage stores remote nodes only; local is represented by null
|
||||
const { result } = renderWithProvider();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should not persist any changes when on local node
|
||||
act(() => {
|
||||
result.current.clearCurrentNode();
|
||||
});
|
||||
|
||||
// Should clear from global settings
|
||||
expect(updateGlobalSettings).toHaveBeenCalledWith({
|
||||
dashboardCurrentNodeId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws error when useNodeContext is used outside NodeProvider", () => {
|
||||
// Suppress console.error for this test
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useNodeContext());
|
||||
}).toThrow("useNodeContext must be used within a NodeProvider");
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user