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:
@@ -47,6 +47,9 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
settingsSyncAuth: false,
|
||||
settingsSyncInterval: 900000,
|
||||
settingsSyncConflictResolution: "last-write-wins",
|
||||
// Dashboard session state (persisted to global settings for PWA/offline restore)
|
||||
dashboardCurrentNodeId: undefined,
|
||||
dashboardCurrentProjectIdByNode: undefined,
|
||||
} satisfies CompleteSettings<GlobalSettings>;
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
|
||||
@@ -1027,6 +1027,16 @@ export interface GlobalSettings {
|
||||
* - "keep-remote": Accept the remote version on conflict
|
||||
* Default: "last-write-wins". */
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,19 +64,36 @@ function AppInner() {
|
||||
// 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 { 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 {
|
||||
hasAiProvider,
|
||||
hasGithub,
|
||||
loading: setupReadinessLoading,
|
||||
hasWarnings,
|
||||
} = 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(() => {
|
||||
// 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) {
|
||||
const nodeExists = nodes.some((n) => n.id === currentNodeId);
|
||||
if (!nodeExists) {
|
||||
@@ -84,7 +101,7 @@ function AppInner() {
|
||||
clearCurrentNode();
|
||||
}
|
||||
}
|
||||
}, [currentNodeId, nodes, clearCurrentNode]);
|
||||
}, [currentNodeId, currentNode, nodes, setCurrentNode, clearCurrentNode]);
|
||||
|
||||
// Search query state - must be defined before useTasks
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -165,6 +165,30 @@ describe("useViewState", () => {
|
||||
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 ─────────────────────────────────────
|
||||
|
||||
it("reads saved insights taskView from scoped localStorage on init", async () => {
|
||||
|
||||
@@ -3,6 +3,14 @@ import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useCurrentProject } from "./useCurrentProject";
|
||||
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", () => {
|
||||
const mockProjects: ProjectInfo[] = [
|
||||
{
|
||||
@@ -28,6 +36,9 @@ describe("useCurrentProject", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
// Default mock implementations
|
||||
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
(updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -57,8 +68,10 @@ describe("useCurrentProject", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads saved project from localStorage", async () => {
|
||||
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(mockProjects[0]));
|
||||
it("loads saved project from global settings", async () => {
|
||||
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
dashboardCurrentProjectIdByNode: { local: "proj_1" },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCurrentProject(mockProjects));
|
||||
|
||||
@@ -66,9 +79,26 @@ describe("useCurrentProject", () => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// After validation, it should have the saved project
|
||||
// After hydration, it should have the saved project
|
||||
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 () => {
|
||||
const unregisteredProject: ProjectInfo = {
|
||||
id: "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));
|
||||
it("clears selection when project no longer exists and defaults to first active", async () => {
|
||||
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
dashboardCurrentProjectIdByNode: { local: "proj_old" },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCurrentProject(mockProjects));
|
||||
|
||||
@@ -108,9 +130,16 @@ describe("useCurrentProject", () => {
|
||||
await waitFor(() => {
|
||||
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));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -122,11 +151,35 @@ describe("useCurrentProject", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(mockProjects[1]));
|
||||
it("setCurrentProject uses node ID as key when provided", async () => {
|
||||
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));
|
||||
|
||||
@@ -134,9 +187,9 @@ describe("useCurrentProject", () => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// After loading, we should have proj_2 from localStorage
|
||||
// After loading, we should have proj_1
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentProject?.id).toBe("proj_2");
|
||||
expect(result.current.currentProject?.id).toBe("proj_1");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
@@ -148,17 +201,32 @@ describe("useCurrentProject", () => {
|
||||
expect(result.current.currentProject).toBeNull();
|
||||
});
|
||||
|
||||
// localStorage should be cleared
|
||||
expect(localStorage.getItem("kb-dashboard-current-project")).toBeNull();
|
||||
// Should persist the clear (remove the key)
|
||||
expect(updateGlobalSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dashboardCurrentProjectIdByNode: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles localStorage errors gracefully", async () => {
|
||||
// Mock localStorage to throw
|
||||
const originalSetItem = localStorage.setItem;
|
||||
localStorage.setItem = vi.fn(() => {
|
||||
throw new Error("Storage error");
|
||||
it("handles global settings fetch failure gracefully", async () => {
|
||||
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useCurrentProject(mockProjects));
|
||||
|
||||
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));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -166,13 +234,34 @@ describe("useCurrentProject", () => {
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentProject(mockProjects[0]);
|
||||
result.current.setCurrentProject(mockProjects[1]);
|
||||
});
|
||||
|
||||
// Should still update state even if localStorage fails
|
||||
expect(result.current.currentProject?.id).toBe("proj_1");
|
||||
// Should still update state even if persistence fails
|
||||
expect(result.current.currentProject?.id).toBe("proj_2");
|
||||
});
|
||||
|
||||
// Restore
|
||||
localStorage.setItem = originalSetItem;
|
||||
it("migrates legacy localStorage to global settings", async () => {
|
||||
// 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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
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 {
|
||||
/** Currently selected project or null if none selected */
|
||||
@@ -10,37 +20,106 @@ export interface UseCurrentProjectResult {
|
||||
setCurrentProject: (project: ProjectInfo | null) => void;
|
||||
/** Clear the current project selection (suppresses auto-select) */
|
||||
clearCurrentProject: () => void;
|
||||
/** Whether we're still loading from localStorage */
|
||||
/** Whether we're still loading from global settings */
|
||||
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.
|
||||
* 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 [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")
|
||||
// and we should not auto-select until they pick one manually.
|
||||
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(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as ProjectInfo;
|
||||
setCurrentProjectState(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
let cancelled = 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(() => {
|
||||
if (loading) return;
|
||||
|
||||
@@ -54,12 +133,12 @@ export function useCurrentProject(availableProjects: ProjectInfo[]): UseCurrentP
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist to localStorage
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentProject));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
// Persist to global settings
|
||||
const newCache = { ...settingsCacheRef.current, [nodeKey]: currentProject.id };
|
||||
settingsCacheRef.current = newCache;
|
||||
updateGlobalSettings({ dashboardCurrentProjectIdByNode: newCache }).catch(() => {
|
||||
// Non-critical - persistence failed
|
||||
});
|
||||
} else if (availableProjects.length > 0 && !explicitlyClearedRef.current) {
|
||||
// No selection but projects available - default to first active
|
||||
// Skip if user explicitly cleared (navigated to overview)
|
||||
@@ -68,35 +147,36 @@ export function useCurrentProject(availableProjects: ProjectInfo[]): UseCurrentP
|
||||
setCurrentProjectState(firstActive);
|
||||
}
|
||||
}
|
||||
}, [currentProject, availableProjects, loading]);
|
||||
}, [currentProject, availableProjects, loading, nodeKey]);
|
||||
|
||||
const setCurrentProject = useCallback((project: ProjectInfo | null) => {
|
||||
explicitlyClearedRef.current = false;
|
||||
setCurrentProjectState(project);
|
||||
if (project) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
const setCurrentProject = useCallback(
|
||||
(project: ProjectInfo | null) => {
|
||||
explicitlyClearedRef.current = false;
|
||||
setCurrentProjectState(project);
|
||||
|
||||
if (project) {
|
||||
const newCache = { ...settingsCacheRef.current, [nodeKey]: project.id };
|
||||
settingsCacheRef.current = newCache;
|
||||
updateGlobalSettings({ dashboardCurrentProjectIdByNode: newCache }).catch(() => {
|
||||
// Non-critical - persistence failed
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[nodeKey],
|
||||
);
|
||||
|
||||
const clearCurrentProject = useCallback(() => {
|
||||
explicitlyClearedRef.current = true;
|
||||
setCurrentProjectState(null);
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Remove from cache and persist
|
||||
const newCache = { ...settingsCacheRef.current };
|
||||
delete newCache[nodeKey];
|
||||
settingsCacheRef.current = newCache;
|
||||
updateGlobalSettings({ dashboardCurrentProjectIdByNode: newCache }).catch(() => {
|
||||
// Non-critical - persistence failed
|
||||
});
|
||||
}, [nodeKey]);
|
||||
|
||||
return {
|
||||
currentProject,
|
||||
|
||||
Reference in New Issue
Block a user