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

@@ -63,6 +63,8 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `settingsSyncAuth` | `boolean` | `false` | Include model auth credentials in settings sync operations. | | `settingsSyncAuth` | `boolean` | `false` | Include model auth credentials in settings sync operations. |
| `settingsSyncInterval` | `number` | `900000` | Automatic sync interval in ms. Valid values: `300000`, `900000`, `1800000`, `3600000`. | | `settingsSyncInterval` | `number` | `900000` | Automatic sync interval in ms. Valid values: `300000`, `900000`, `1800000`, `3600000`. |
| `settingsSyncConflictResolution` | `"last-write-wins" \| "always-ask" \| "keep-local" \| "keep-remote"` | `"last-write-wins"` | Conflict strategy for divergent synced settings. | | `settingsSyncConflictResolution` | `"last-write-wins" \| "always-ask" \| "keep-local" \| "keep-remote"` | `"last-write-wins"` | Conflict strategy for divergent synced settings. |
| `dashboardCurrentNodeId` | `string` | `undefined` | Currently selected dashboard node ID. Restores the last-viewed node on fresh browser/PWA sessions. `undefined` means viewing the local node. |
| `dashboardCurrentProjectIdByNode` | `Record<string, string>` | `undefined` | Map of node ID to last-selected project ID. Use key `"local"` for the local node. Persists project context across browser restarts and PWA sessions. |
--- ---

View File

@@ -47,6 +47,9 @@ export const DEFAULT_GLOBAL_SETTINGS = {
settingsSyncAuth: false, settingsSyncAuth: false,
settingsSyncInterval: 900000, settingsSyncInterval: 900000,
settingsSyncConflictResolution: "last-write-wins", settingsSyncConflictResolution: "last-write-wins",
// Dashboard session state (persisted to global settings for PWA/offline restore)
dashboardCurrentNodeId: undefined,
dashboardCurrentProjectIdByNode: undefined,
} satisfies CompleteSettings<GlobalSettings>; } satisfies CompleteSettings<GlobalSettings>;
/** Default values for project-level settings. */ /** Default values for project-level settings. */

View File

@@ -1027,6 +1027,16 @@ export interface GlobalSettings {
* - "keep-remote": Accept the remote version on conflict * - "keep-remote": Accept the remote version on conflict
* Default: "last-write-wins". */ * Default: "last-write-wins". */
settingsSyncConflictResolution?: "last-write-wins" | "always-ask" | "keep-local" | "keep-remote"; settingsSyncConflictResolution?: "last-write-wins" | "always-ask" | "keep-local" | "keep-remote";
/** Currently selected dashboard node ID. Used to restore the last-viewed node
* on fresh browser/PWA sessions. Null or undefined means viewing the local node.
* Persisted to global settings so it survives across browser restarts. */
dashboardCurrentNodeId?: string;
/** Map of node ID to the last-selected project ID for that node.
* The key is the node ID (use `"local"` for the local node).
* Persisted to global settings so project context is restored on fresh sessions.
* Clear individual entries by setting them to `undefined` (omitting from update).
* Clearing all entries returns the dashboard to overview mode. */
dashboardCurrentProjectIdByNode?: Record<string, string>;
} }
/** /**

View File

@@ -64,19 +64,36 @@ function AppInner() {
// Project management hooks - MUST be called before any conditional logic // Project management hooks - MUST be called before any conditional logic
const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects, register: registerProject, update: updateProjectHook, unregister: unregisterProjectHook } = useProjects(); const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects, register: registerProject, update: updateProjectHook, unregister: unregisterProjectHook } = useProjects();
const { nodes } = useNodes(); const { nodes } = useNodes();
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
// Node context for local/remote node switching - must be called before useCurrentProject
const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext();
// Current project with node-aware persistence
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects, { nodeId: currentNodeId });
const { const {
hasAiProvider, hasAiProvider,
hasGithub, hasGithub,
loading: setupReadinessLoading, loading: setupReadinessLoading,
hasWarnings, hasWarnings,
} = useSetupReadiness(currentProject?.id); } = useSetupReadiness(currentProject?.id);
// Node context for local/remote node switching
const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext();
// Sync node context with useNodes() results - fall back to local if selected node is missing // Sync node context with useNodes() results:
// - Resolve saved node ID to full NodeConfig when nodes list loads
// - Fall back to local if selected node is missing or deleted
useEffect(() => { useEffect(() => {
// If we have a saved node ID but no currentNode yet (initial hydration),
// resolve it from the nodes list
if (currentNodeId && !currentNode && nodes.length > 0) {
const foundNode = nodes.find((n) => n.id === currentNodeId);
if (foundNode) {
setCurrentNode(foundNode);
return;
}
}
// If we have a currentNode but the saved ID no longer exists in nodes list,
// fall back to local view
if (currentNodeId && nodes.length > 0) { if (currentNodeId && nodes.length > 0) {
const nodeExists = nodes.some((n) => n.id === currentNodeId); const nodeExists = nodes.some((n) => n.id === currentNodeId);
if (!nodeExists) { if (!nodeExists) {
@@ -84,7 +101,7 @@ function AppInner() {
clearCurrentNode(); clearCurrentNode();
} }
} }
}, [currentNodeId, nodes, clearCurrentNode]); }, [currentNodeId, currentNode, nodes, setCurrentNode, clearCurrentNode]);
// Search query state - must be defined before useTasks // Search query state - must be defined before useTasks
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");

View File

@@ -1,12 +1,15 @@
/** /**
* NodeContext provides React context for tracking which node the dashboard is currently viewing. * 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. * 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 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 { export interface NodeContextValue {
/** Currently selected node or null if viewing local node */ /** Currently selected node or null if viewing local node */
@@ -29,43 +32,93 @@ export interface NodeProviderProps {
/** /**
* Provider component that manages the current node state. * 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) { export function NodeProvider({ children }: NodeProviderProps) {
const [currentNode, setCurrentNodeState] = useState<NodeConfig | null>(null); 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(() => { useEffect(() => {
try { let cancelled = false;
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) { async function loadFromGlobalSettings() {
const parsed = JSON.parse(saved) as NodeConfig; try {
// Only restore if it's a remote node const settings = await fetchGlobalSettings();
if (parsed && parsed.type === "remote") {
setCurrentNodeState(parsed); 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(() => { useEffect(() => {
if (currentNode && currentNode.type === "remote") { if (loading) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentNode)); // Only persist if we've hydrated and have a remote node
} catch { if (currentNode && currentNode.type === "remote" && currentNode.id) {
// Ignore localStorage errors const newId = currentNode.id;
} if (settingsCacheRef.current !== newId) {
} else { settingsCacheRef.current = newId;
try { updateGlobalSettings({ dashboardCurrentNodeId: newId }).catch(() => {
localStorage.removeItem(STORAGE_KEY); // Non-critical - persistence failed
} catch { });
// Ignore localStorage errors
} }
} 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) => { const setCurrentNode = useCallback((node: NodeConfig | null) => {
setCurrentNodeState(node); setCurrentNodeState(node);
@@ -73,11 +126,11 @@ export function NodeProvider({ children }: NodeProviderProps) {
const clearCurrentNode = useCallback(() => { const clearCurrentNode = useCallback(() => {
setCurrentNodeState(null); setCurrentNodeState(null);
try { // Clear from cache immediately for responsiveness
localStorage.removeItem(STORAGE_KEY); settingsCacheRef.current = undefined;
} catch { updateGlobalSettings({ dashboardCurrentNodeId: undefined }).catch(() => {
// Ignore localStorage errors // Non-critical - persistence failed
} });
}, []); }, []);
const value: NodeContextValue = { const value: NodeContextValue = {

View 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();
});
});

View File

@@ -165,6 +165,30 @@ describe("useViewState", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it("does NOT call openSetupWizard when projects exist even if no current project selected", async () => {
vi.useFakeTimers();
const openSetupWizard = vi.fn();
renderHook(() =>
useViewState(
createOptions({
projectsLength: 3, // Projects exist
currentProject: null, // But none selected yet
openSetupWizard,
}),
),
);
await act(async () => {
vi.advanceTimersByTime(500);
});
// Should NOT open setup wizard when projects already exist
// The dashboard should show overview mode to let user pick a project
expect(openSetupWizard).not.toHaveBeenCalled();
vi.useRealTimers();
});
// ── Insights view persistence ───────────────────────────────────── // ── Insights view persistence ─────────────────────────────────────
it("reads saved insights taskView from scoped localStorage on init", async () => { it("reads saved insights taskView from scoped localStorage on init", async () => {

View File

@@ -3,6 +3,14 @@ import { renderHook, waitFor, act } from "@testing-library/react";
import { useCurrentProject } from "./useCurrentProject"; import { useCurrentProject } from "./useCurrentProject";
import type { ProjectInfo } from "../api"; import type { ProjectInfo } from "../api";
// Mock the API functions
vi.mock("../api", () => ({
fetchGlobalSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
}));
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
describe("useCurrentProject", () => { describe("useCurrentProject", () => {
const mockProjects: ProjectInfo[] = [ const mockProjects: ProjectInfo[] = [
{ {
@@ -28,6 +36,9 @@ describe("useCurrentProject", () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
vi.clearAllMocks(); vi.clearAllMocks();
// Default mock implementations
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
(updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
}); });
afterEach(() => { afterEach(() => {
@@ -57,8 +68,10 @@ describe("useCurrentProject", () => {
}); });
}); });
it("loads saved project from localStorage", async () => { it("loads saved project from global settings", async () => {
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(mockProjects[0])); (fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
dashboardCurrentProjectIdByNode: { local: "proj_1" },
});
const { result } = renderHook(() => useCurrentProject(mockProjects)); const { result } = renderHook(() => useCurrentProject(mockProjects));
@@ -66,9 +79,26 @@ describe("useCurrentProject", () => {
expect(result.current.loading).toBe(false); expect(result.current.loading).toBe(false);
}); });
// After validation, it should have the saved project // After hydration, it should have the saved project
await waitFor(() => { await waitFor(() => {
expect(result.current.currentProject).not.toBeNull(); expect(result.current.currentProject?.id).toBe("proj_1");
});
});
it("loads saved project for specific node ID", async () => {
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
dashboardCurrentProjectIdByNode: { "node-123": "proj_2" },
});
const { result } = renderHook(() => useCurrentProject(mockProjects, { nodeId: "node-123" }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Should load proj_2 for node-123
await waitFor(() => {
expect(result.current.currentProject?.id).toBe("proj_2");
}); });
}); });
@@ -85,18 +115,10 @@ describe("useCurrentProject", () => {
}); });
}); });
it("clears selection when project no longer exists", async () => { it("clears selection when project no longer exists and defaults to first active", async () => {
const unregisteredProject: ProjectInfo = { (fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "proj_old", dashboardCurrentProjectIdByNode: { local: "proj_old" },
name: "Old Project", });
path: "/old/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(unregisteredProject));
const { result } = renderHook(() => useCurrentProject(mockProjects)); const { result } = renderHook(() => useCurrentProject(mockProjects));
@@ -108,9 +130,16 @@ describe("useCurrentProject", () => {
await waitFor(() => { await waitFor(() => {
expect(result.current.currentProject?.id).toBe("proj_1"); expect(result.current.currentProject?.id).toBe("proj_1");
}); });
// Should persist the new selection
expect(updateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
dashboardCurrentProjectIdByNode: { local: "proj_1" },
}),
);
}); });
it("setCurrentProject updates selection and saves to localStorage", async () => { it("setCurrentProject updates selection and persists to global settings", async () => {
const { result } = renderHook(() => useCurrentProject(mockProjects)); const { result } = renderHook(() => useCurrentProject(mockProjects));
await waitFor(() => { await waitFor(() => {
@@ -122,11 +151,35 @@ describe("useCurrentProject", () => {
}); });
expect(result.current.currentProject?.id).toBe("proj_2"); expect(result.current.currentProject?.id).toBe("proj_2");
expect(localStorage.getItem("kb-dashboard-current-project")).toContain("proj_2"); expect(updateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
dashboardCurrentProjectIdByNode: { local: "proj_2" },
}),
);
}); });
it("clearCurrentProject removes selection and re-defaults when projects available", async () => { it("setCurrentProject uses node ID as key when provided", async () => {
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(mockProjects[1])); const { result } = renderHook(() => useCurrentProject(mockProjects, { nodeId: "node-456" }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
act(() => {
result.current.setCurrentProject(mockProjects[1]);
});
expect(updateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
dashboardCurrentProjectIdByNode: { "node-456": "proj_2" },
}),
);
});
it("clearCurrentProject removes selection and does not auto-select", async () => {
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
dashboardCurrentProjectIdByNode: { local: "proj_1" },
});
const { result } = renderHook(() => useCurrentProject(mockProjects)); const { result } = renderHook(() => useCurrentProject(mockProjects));
@@ -134,9 +187,9 @@ describe("useCurrentProject", () => {
expect(result.current.loading).toBe(false); expect(result.current.loading).toBe(false);
}); });
// After loading, we should have proj_2 from localStorage // After loading, we should have proj_1
await waitFor(() => { await waitFor(() => {
expect(result.current.currentProject?.id).toBe("proj_2"); expect(result.current.currentProject?.id).toBe("proj_1");
}); });
act(() => { act(() => {
@@ -148,17 +201,32 @@ describe("useCurrentProject", () => {
expect(result.current.currentProject).toBeNull(); expect(result.current.currentProject).toBeNull();
}); });
// localStorage should be cleared // Should persist the clear (remove the key)
expect(localStorage.getItem("kb-dashboard-current-project")).toBeNull(); expect(updateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
dashboardCurrentProjectIdByNode: {},
}),
);
}); });
it("handles localStorage errors gracefully", async () => { it("handles global settings fetch failure gracefully", async () => {
// Mock localStorage to throw (fetchGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
const originalSetItem = localStorage.setItem;
localStorage.setItem = vi.fn(() => { const { result } = renderHook(() => useCurrentProject(mockProjects));
throw new Error("Storage error");
await waitFor(() => {
expect(result.current.loading).toBe(false);
}); });
// Should fall back to default behavior (first active project)
await waitFor(() => {
expect(result.current.currentProject?.id).toBe("proj_1");
});
});
it("handles global settings update failure gracefully", async () => {
(updateGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() => useCurrentProject(mockProjects)); const { result } = renderHook(() => useCurrentProject(mockProjects));
await waitFor(() => { await waitFor(() => {
@@ -166,13 +234,34 @@ describe("useCurrentProject", () => {
}); });
act(() => { act(() => {
result.current.setCurrentProject(mockProjects[0]); result.current.setCurrentProject(mockProjects[1]);
}); });
// Should still update state even if localStorage fails // Should still update state even if persistence fails
expect(result.current.currentProject?.id).toBe("proj_1"); expect(result.current.currentProject?.id).toBe("proj_2");
});
// Restore it("migrates legacy localStorage to global settings", async () => {
localStorage.setItem = originalSetItem; // Set up legacy localStorage
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(mockProjects[0]));
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
const { result } = renderHook(() => useCurrentProject(mockProjects));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Should load from legacy localStorage
await waitFor(() => {
expect(result.current.currentProject?.id).toBe("proj_1");
});
// Should migrate to global settings
expect(updateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
dashboardCurrentProjectIdByNode: { local: "proj_1" },
}),
);
}); });
}); });

View File

@@ -1,7 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import type { ProjectInfo } from "../api"; import type { ProjectInfo } from "../api";
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
const STORAGE_KEY = "kb-dashboard-current-project"; // Legacy localStorage key for migration - no longer used as primary storage
const LEGACY_STORAGE_KEY = "kb-dashboard-current-project";
/**
* Get the node key used in dashboardCurrentProjectIdByNode.
* Use "local" for the local node, otherwise use the node ID.
*/
function getNodeKey(nodeId: string | null): string {
return nodeId ?? "local";
}
export interface UseCurrentProjectResult { export interface UseCurrentProjectResult {
/** Currently selected project or null if none selected */ /** Currently selected project or null if none selected */
@@ -10,37 +20,106 @@ export interface UseCurrentProjectResult {
setCurrentProject: (project: ProjectInfo | null) => void; setCurrentProject: (project: ProjectInfo | null) => void;
/** Clear the current project selection (suppresses auto-select) */ /** Clear the current project selection (suppresses auto-select) */
clearCurrentProject: () => void; clearCurrentProject: () => void;
/** Whether we're still loading from localStorage */ /** Whether we're still loading from global settings */
loading: boolean; loading: boolean;
} }
interface UseCurrentProjectOptions {
/** Node ID from NodeContext - used to key project selection per node */
nodeId?: string | null;
}
/** /**
* Hook for managing the currently selected project. * Hook for managing the currently selected project.
* Persists selection to localStorage and validates the project still exists. * Persists selection to global settings (server-backed) instead of localStorage.
* This enables PWA fresh sessions to restore the correct project context.
*/ */
export function useCurrentProject(availableProjects: ProjectInfo[]): UseCurrentProjectResult { export function useCurrentProject(
availableProjects: ProjectInfo[],
options: UseCurrentProjectOptions = {},
): UseCurrentProjectResult {
const { nodeId = null } = options;
const [currentProject, setCurrentProjectState] = useState<ProjectInfo | null>(null); const [currentProject, setCurrentProjectState] = useState<ProjectInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Track if we've hydrated from global settings (vs just initialized)
const hydratedRef = useRef(false);
// When true, the user explicitly cleared the project (e.g. clicked "Projects") // When true, the user explicitly cleared the project (e.g. clicked "Projects")
// and we should not auto-select until they pick one manually. // and we should not auto-select until they pick one manually.
const explicitlyClearedRef = useRef(false); const explicitlyClearedRef = useRef(false);
// Cache of current settings to avoid repeated fetches
const settingsCacheRef = useRef<Record<string, string> | null>(null);
// Load from localStorage on mount const nodeKey = getNodeKey(nodeId);
// Load from global settings on mount
useEffect(() => { useEffect(() => {
try { let cancelled = false;
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as ProjectInfo;
setCurrentProjectState(parsed);
}
} catch {
// Ignore localStorage errors
} finally {
setLoading(false);
}
}, []);
// Validate project still exists and persist to localStorage async function loadFromGlobalSettings() {
try {
const settings = await fetchGlobalSettings();
if (cancelled) return;
// Build cache from settings
settingsCacheRef.current = settings.dashboardCurrentProjectIdByNode ?? {};
const savedProjectId = settingsCacheRef.current[nodeKey];
if (savedProjectId) {
// Try to find the saved project in available projects
const found = availableProjects.find((p) => p.id === savedProjectId);
if (found) {
setCurrentProjectState(found);
hydratedRef.current = true;
}
// If project not found, we'll handle in the next effect
// (project may still be loading or was unregistered)
}
// Also migrate legacy localStorage if no global settings entry exists
if (!savedProjectId) {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
const parsed = JSON.parse(legacy) as ProjectInfo;
if (parsed?.id) {
// Check if project still exists
const exists = availableProjects.some((p) => p.id === parsed.id);
if (exists) {
setCurrentProjectState(parsed);
hydratedRef.current = true;
// Migrate to global settings
settingsCacheRef.current = { ...settingsCacheRef.current, [nodeKey]: parsed.id };
await updateGlobalSettings({
dashboardCurrentProjectIdByNode: settingsCacheRef.current,
}).catch(() => {
// Non-critical - migration failed, but we have the data in memory
});
}
}
}
} catch {
// Ignore legacy localStorage errors
}
}
} catch {
// Global settings fetch failed - this is non-critical
// We'll fall back to default behavior
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadFromGlobalSettings();
return () => {
cancelled = true;
};
}, [nodeKey, availableProjects]);
// Validate project still exists and persist to global settings
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
@@ -54,12 +133,12 @@ export function useCurrentProject(availableProjects: ProjectInfo[]): UseCurrentP
return; return;
} }
// Persist to localStorage // Persist to global settings
try { const newCache = { ...settingsCacheRef.current, [nodeKey]: currentProject.id };
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentProject)); settingsCacheRef.current = newCache;
} catch { updateGlobalSettings({ dashboardCurrentProjectIdByNode: newCache }).catch(() => {
// Ignore localStorage errors // Non-critical - persistence failed
} });
} else if (availableProjects.length > 0 && !explicitlyClearedRef.current) { } else if (availableProjects.length > 0 && !explicitlyClearedRef.current) {
// No selection but projects available - default to first active // No selection but projects available - default to first active
// Skip if user explicitly cleared (navigated to overview) // Skip if user explicitly cleared (navigated to overview)
@@ -68,35 +147,36 @@ export function useCurrentProject(availableProjects: ProjectInfo[]): UseCurrentP
setCurrentProjectState(firstActive); setCurrentProjectState(firstActive);
} }
} }
}, [currentProject, availableProjects, loading]); }, [currentProject, availableProjects, loading, nodeKey]);
const setCurrentProject = useCallback((project: ProjectInfo | null) => { const setCurrentProject = useCallback(
explicitlyClearedRef.current = false; (project: ProjectInfo | null) => {
setCurrentProjectState(project); explicitlyClearedRef.current = false;
if (project) { setCurrentProjectState(project);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(project)); if (project) {
} catch { const newCache = { ...settingsCacheRef.current, [nodeKey]: project.id };
// Ignore localStorage errors settingsCacheRef.current = newCache;
updateGlobalSettings({ dashboardCurrentProjectIdByNode: newCache }).catch(() => {
// Non-critical - persistence failed
});
} }
} else { },
try { [nodeKey],
localStorage.removeItem(STORAGE_KEY); );
} catch {
// Ignore localStorage errors
}
}
}, []);
const clearCurrentProject = useCallback(() => { const clearCurrentProject = useCallback(() => {
explicitlyClearedRef.current = true; explicitlyClearedRef.current = true;
setCurrentProjectState(null); setCurrentProjectState(null);
try {
localStorage.removeItem(STORAGE_KEY); // Remove from cache and persist
} catch { const newCache = { ...settingsCacheRef.current };
// Ignore localStorage errors delete newCache[nodeKey];
} settingsCacheRef.current = newCache;
}, []); updateGlobalSettings({ dashboardCurrentProjectIdByNode: newCache }).catch(() => {
// Non-critical - persistence failed
});
}, [nodeKey]);
return { return {
currentProject, currentProject,