Files
fusion/packages/dashboard/app/hooks/useCurrentProject.ts
Fusion 85a1adbd0b 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
2026-04-22 17:12:27 -07:00

188 lines
6.7 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import type { ProjectInfo } from "../api";
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
// 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 */
currentProject: ProjectInfo | null;
/** Set the current project */
setCurrentProject: (project: ProjectInfo | null) => void;
/** Clear the current project selection (suppresses auto-select) */
clearCurrentProject: () => void;
/** 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 global settings (server-backed) instead of localStorage.
* This enables PWA fresh sessions to restore the correct project context.
*/
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);
const nodeKey = getNodeKey(nodeId);
// Load from global settings on mount
useEffect(() => {
let cancelled = false;
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;
if (currentProject) {
// Validate project still exists in available projects
const stillExists = availableProjects.some((p) => p.id === currentProject.id);
if (!stillExists && availableProjects.length > 0) {
// Project was unregistered - clear selection and default to first active
const firstActive = availableProjects.find((p) => p.status === "active");
setCurrentProjectState(firstActive || availableProjects[0] || null);
return;
}
// 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)
const firstActive = availableProjects.find((p) => p.status === "active");
if (firstActive) {
setCurrentProjectState(firstActive);
}
}
}, [currentProject, availableProjects, loading, nodeKey]);
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
});
}
},
[nodeKey],
);
const clearCurrentProject = useCallback(() => {
explicitlyClearedRef.current = true;
setCurrentProjectState(null);
// 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,
setCurrentProject,
clearCurrentProject,
loading,
};
}