feat(canvas): Studio Enhancement Phase 1 & 2 - v2.0 architecture and file structure
Phase 1 - Foundation:
- Add NodeConfigPanelV2 using useSpecStore for AtomizerSpec v2.0 mode
- Deprecate AtomizerCanvas and useCanvasStore with migration docs
- Add VITE_USE_LEGACY_CANVAS env var for emergency fallback
- Enhance NodePalette with collapse support, filtering, exports
- Add drag-drop support to SpecRenderer with default node data
- Setup test infrastructure (Vitest + Playwright configs)
- Add useSpecStore unit tests (15 tests)
Phase 2 - File Structure & Model:
- Create FileStructurePanel with tree view of study files
- Add ModelNodeV2 with collapsible file dependencies
- Add tabbed left sidebar (Components/Files tabs)
- Add GET /api/files/structure/{study_id} backend endpoint
- Auto-expand 1_setup folders in file tree
- Show model file introspection with solver type and expressions
Technical:
- All TypeScript checks pass
- All 15 unit tests pass
- Production build successful
This commit is contained in:
@@ -1,3 +1,19 @@
|
||||
/**
|
||||
* @deprecated This store is deprecated as of January 2026.
|
||||
* Use useSpecStore instead, which works with AtomizerSpec v2.0.
|
||||
*
|
||||
* Migration guide:
|
||||
* - Import useSpecStore from '../hooks/useSpecStore' instead
|
||||
* - Use spec.design_variables, spec.extractors, etc. instead of nodes/edges
|
||||
* - Use addNode(), updateNode(), removeNode() instead of canvas mutations
|
||||
* - Spec changes sync automatically via WebSocket
|
||||
*
|
||||
* This store is kept for emergency fallback only with AtomizerCanvas.
|
||||
*
|
||||
* @see useSpecStore for the new state management
|
||||
* @see AtomizerSpec v2.0 documentation
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { Node, Edge, addEdge, applyNodeChanges, applyEdgeChanges, Connection, NodeChange, EdgeChange } from 'reactflow';
|
||||
import { CanvasNodeData, NodeType } from '../lib/canvas/schema';
|
||||
|
||||
209
atomizer-dashboard/frontend/src/hooks/useSpecStore.test.ts
Normal file
209
atomizer-dashboard/frontend/src/hooks/useSpecStore.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* useSpecStore Unit Tests
|
||||
*
|
||||
* Tests for the AtomizerSpec v2.0 state management store.
|
||||
*/
|
||||
|
||||
/// <reference types="vitest/globals" />
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useSpecStore } from './useSpecStore';
|
||||
import { createMockSpec, mockFetch } from '../test/utils';
|
||||
|
||||
// Type for global context
|
||||
declare const global: typeof globalThis;
|
||||
|
||||
describe('useSpecStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the store state before each test
|
||||
useSpecStore.setState({
|
||||
spec: null,
|
||||
studyId: null,
|
||||
hash: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
validation: null,
|
||||
selectedNodeId: null,
|
||||
selectedEdgeId: null,
|
||||
isDirty: false,
|
||||
pendingChanges: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have null spec initially', () => {
|
||||
const { spec } = useSpecStore.getState();
|
||||
expect(spec).toBeNull();
|
||||
});
|
||||
|
||||
it('should not be loading initially', () => {
|
||||
const { isLoading } = useSpecStore.getState();
|
||||
expect(isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should have no selected node initially', () => {
|
||||
const { selectedNodeId } = useSpecStore.getState();
|
||||
expect(selectedNodeId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection', () => {
|
||||
it('should select a node', () => {
|
||||
const { selectNode } = useSpecStore.getState();
|
||||
selectNode('dv_001');
|
||||
|
||||
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
|
||||
expect(selectedNodeId).toBe('dv_001');
|
||||
expect(selectedEdgeId).toBeNull();
|
||||
});
|
||||
|
||||
it('should select an edge', () => {
|
||||
const { selectEdge } = useSpecStore.getState();
|
||||
selectEdge('edge_1');
|
||||
|
||||
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
|
||||
expect(selectedEdgeId).toBe('edge_1');
|
||||
expect(selectedNodeId).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear selection', () => {
|
||||
const { selectNode, clearSelection } = useSpecStore.getState();
|
||||
selectNode('dv_001');
|
||||
clearSelection();
|
||||
|
||||
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
|
||||
expect(selectedNodeId).toBeNull();
|
||||
expect(selectedEdgeId).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear edge when selecting node', () => {
|
||||
const { selectEdge, selectNode } = useSpecStore.getState();
|
||||
selectEdge('edge_1');
|
||||
selectNode('dv_001');
|
||||
|
||||
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
|
||||
expect(selectedNodeId).toBe('dv_001');
|
||||
expect(selectedEdgeId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSpecFromWebSocket', () => {
|
||||
it('should set spec directly', () => {
|
||||
const mockSpec = createMockSpec({ meta: { study_name: 'ws_test' } });
|
||||
const { setSpecFromWebSocket } = useSpecStore.getState();
|
||||
|
||||
setSpecFromWebSocket(mockSpec, 'test_study');
|
||||
|
||||
const { spec, studyId, isLoading, error } = useSpecStore.getState();
|
||||
expect(spec?.meta.study_name).toBe('ws_test');
|
||||
expect(studyId).toBe('test_study');
|
||||
expect(isLoading).toBe(false);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSpec', () => {
|
||||
it('should set loading state', async () => {
|
||||
mockFetch({
|
||||
'spec': createMockSpec(),
|
||||
'hash': { hash: 'abc123' },
|
||||
});
|
||||
|
||||
const { loadSpec } = useSpecStore.getState();
|
||||
const loadPromise = loadSpec('test_study');
|
||||
|
||||
// Should be loading immediately
|
||||
expect(useSpecStore.getState().isLoading).toBe(true);
|
||||
|
||||
await loadPromise;
|
||||
|
||||
// Should no longer be loading
|
||||
expect(useSpecStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { loadSpec } = useSpecStore.getState();
|
||||
|
||||
await loadSpec('test_study');
|
||||
|
||||
const { error, isLoading } = useSpecStore.getState();
|
||||
expect(error).toContain('error');
|
||||
expect(isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeById', () => {
|
||||
beforeEach(() => {
|
||||
const mockSpec = createMockSpec({
|
||||
design_variables: [
|
||||
{ id: 'dv_001', name: 'thickness', expression_name: 't', type: 'continuous', bounds: { min: 1, max: 10 } },
|
||||
{ id: 'dv_002', name: 'width', expression_name: 'w', type: 'continuous', bounds: { min: 5, max: 20 } },
|
||||
],
|
||||
extractors: [
|
||||
{ id: 'ext_001', name: 'displacement', type: 'displacement', outputs: ['d'] },
|
||||
],
|
||||
objectives: [
|
||||
{ id: 'obj_001', name: 'mass', type: 'minimize', weight: 1.0 },
|
||||
],
|
||||
});
|
||||
|
||||
useSpecStore.setState({ spec: mockSpec });
|
||||
});
|
||||
|
||||
it('should find design variable by id', () => {
|
||||
const { getNodeById } = useSpecStore.getState();
|
||||
const node = getNodeById('dv_001');
|
||||
|
||||
expect(node).not.toBeNull();
|
||||
expect((node as any).name).toBe('thickness');
|
||||
});
|
||||
|
||||
it('should find extractor by id', () => {
|
||||
const { getNodeById } = useSpecStore.getState();
|
||||
const node = getNodeById('ext_001');
|
||||
|
||||
expect(node).not.toBeNull();
|
||||
expect((node as any).name).toBe('displacement');
|
||||
});
|
||||
|
||||
it('should find objective by id', () => {
|
||||
const { getNodeById } = useSpecStore.getState();
|
||||
const node = getNodeById('obj_001');
|
||||
|
||||
expect(node).not.toBeNull();
|
||||
expect((node as any).name).toBe('mass');
|
||||
});
|
||||
|
||||
it('should return null for unknown id', () => {
|
||||
const { getNodeById } = useSpecStore.getState();
|
||||
const node = getNodeById('unknown_999');
|
||||
|
||||
expect(node).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSpec', () => {
|
||||
it('should reset all state', () => {
|
||||
// Set up some state
|
||||
useSpecStore.setState({
|
||||
spec: createMockSpec(),
|
||||
studyId: 'test',
|
||||
hash: 'abc',
|
||||
selectedNodeId: 'dv_001',
|
||||
isDirty: true,
|
||||
});
|
||||
|
||||
const { clearSpec } = useSpecStore.getState();
|
||||
clearSpec();
|
||||
|
||||
const state = useSpecStore.getState();
|
||||
expect(state.spec).toBeNull();
|
||||
expect(state.studyId).toBeNull();
|
||||
expect(state.hash).toBeNull();
|
||||
expect(state.selectedNodeId).toBeNull();
|
||||
expect(state.isDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
742
atomizer-dashboard/frontend/src/hooks/useSpecStore.ts
Normal file
742
atomizer-dashboard/frontend/src/hooks/useSpecStore.ts
Normal file
@@ -0,0 +1,742 @@
|
||||
/**
|
||||
* useSpecStore - Zustand store for AtomizerSpec v2.0
|
||||
*
|
||||
* Central state management for the unified configuration system.
|
||||
* All spec modifications flow through this store and sync with backend.
|
||||
*
|
||||
* Features:
|
||||
* - Load spec from backend API
|
||||
* - Optimistic updates with rollback on error
|
||||
* - Patch operations via JSONPath
|
||||
* - Node CRUD operations
|
||||
* - Hash-based conflict detection
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, subscribeWithSelector } from 'zustand/middleware';
|
||||
import {
|
||||
AtomizerSpec,
|
||||
DesignVariable,
|
||||
Extractor,
|
||||
Objective,
|
||||
Constraint,
|
||||
CanvasPosition,
|
||||
SpecValidationReport,
|
||||
SpecModification,
|
||||
} from '../types/atomizer-spec';
|
||||
|
||||
// API base URL
|
||||
const API_BASE = '/api';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface SpecStoreState {
|
||||
// Spec data
|
||||
spec: AtomizerSpec | null;
|
||||
studyId: string | null;
|
||||
hash: string | null;
|
||||
|
||||
// Loading state
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Validation
|
||||
validation: SpecValidationReport | null;
|
||||
|
||||
// Selection state (for canvas)
|
||||
selectedNodeId: string | null;
|
||||
selectedEdgeId: string | null;
|
||||
|
||||
// Dirty tracking
|
||||
isDirty: boolean;
|
||||
pendingChanges: SpecModification[];
|
||||
}
|
||||
|
||||
interface SpecStoreActions {
|
||||
// Loading
|
||||
loadSpec: (studyId: string) => Promise<void>;
|
||||
reloadSpec: () => Promise<void>;
|
||||
clearSpec: () => void;
|
||||
|
||||
// WebSocket integration - set spec directly without API call
|
||||
setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => void;
|
||||
|
||||
// Full spec operations
|
||||
saveSpec: (spec: AtomizerSpec) => Promise<void>;
|
||||
replaceSpec: (spec: AtomizerSpec) => Promise<void>;
|
||||
|
||||
// Patch operations
|
||||
patchSpec: (path: string, value: unknown) => Promise<void>;
|
||||
patchSpecOptimistic: (path: string, value: unknown) => void;
|
||||
|
||||
// Node operations
|
||||
addNode: (
|
||||
type: 'designVar' | 'extractor' | 'objective' | 'constraint',
|
||||
data: Record<string, unknown>
|
||||
) => Promise<string>;
|
||||
updateNode: (nodeId: string, updates: Record<string, unknown>) => Promise<void>;
|
||||
removeNode: (nodeId: string) => Promise<void>;
|
||||
updateNodePosition: (nodeId: string, position: CanvasPosition) => Promise<void>;
|
||||
|
||||
// Edge operations
|
||||
addEdge: (source: string, target: string) => Promise<void>;
|
||||
removeEdge: (source: string, target: string) => Promise<void>;
|
||||
|
||||
// Custom function
|
||||
addCustomFunction: (
|
||||
name: string,
|
||||
code: string,
|
||||
outputs: string[],
|
||||
description?: string
|
||||
) => Promise<string>;
|
||||
|
||||
// Validation
|
||||
validateSpec: () => Promise<SpecValidationReport>;
|
||||
|
||||
// Selection
|
||||
selectNode: (nodeId: string | null) => void;
|
||||
selectEdge: (edgeId: string | null) => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
// Utility
|
||||
getNodeById: (nodeId: string) => DesignVariable | Extractor | Objective | Constraint | null;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
type SpecStore = SpecStoreState & SpecStoreActions;
|
||||
|
||||
// ============================================================================
|
||||
// API Functions
|
||||
// ============================================================================
|
||||
|
||||
async function fetchSpec(studyId: string): Promise<{ spec: AtomizerSpec; hash: string }> {
|
||||
const response = await fetch(`${API_BASE}/studies/${studyId}/spec`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Failed to load spec' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const spec = await response.json();
|
||||
|
||||
// Get hash
|
||||
const hashResponse = await fetch(`${API_BASE}/studies/${studyId}/spec/hash`);
|
||||
const { hash } = await hashResponse.json();
|
||||
|
||||
return { spec, hash };
|
||||
}
|
||||
|
||||
async function patchSpecApi(
|
||||
studyId: string,
|
||||
path: string,
|
||||
value: unknown
|
||||
): Promise<{ hash: string; modified: string }> {
|
||||
const response = await fetch(`${API_BASE}/studies/${studyId}/spec`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, value, modified_by: 'canvas' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Patch failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function addNodeApi(
|
||||
studyId: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>
|
||||
): Promise<{ node_id: string }> {
|
||||
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type, data, modified_by: 'canvas' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Add node failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function updateNodeApi(
|
||||
studyId: string,
|
||||
nodeId: string,
|
||||
updates: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ updates, modified_by: 'canvas' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Update node failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNodeApi(studyId: string, nodeId: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}?modified_by=canvas`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Delete node failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addEdgeApi(studyId: string, source: string, target: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Add edge failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEdgeApi(studyId: string, source: string, target: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Remove edge failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addCustomFunctionApi(
|
||||
studyId: string,
|
||||
name: string,
|
||||
code: string,
|
||||
outputs: string[],
|
||||
description?: string
|
||||
): Promise<{ node_id: string }> {
|
||||
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/custom-functions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, code, outputs, description, modified_by: 'claude' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Add custom function failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function validateSpecApi(studyId: string): Promise<SpecValidationReport> {
|
||||
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/validate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Validation failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function applyPatchLocally(spec: AtomizerSpec, path: string, value: unknown): AtomizerSpec {
|
||||
// Deep clone spec
|
||||
const newSpec = JSON.parse(JSON.stringify(spec)) as AtomizerSpec;
|
||||
|
||||
// Parse path and apply value
|
||||
const parts = path.split(/\.|\[|\]/).filter(Boolean);
|
||||
|
||||
let current: Record<string, unknown> = newSpec as unknown as Record<string, unknown>;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
const index = parseInt(part, 10);
|
||||
if (!isNaN(index)) {
|
||||
current = (current as unknown as unknown[])[index] as Record<string, unknown>;
|
||||
} else {
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
const finalKey = parts[parts.length - 1];
|
||||
const index = parseInt(finalKey, 10);
|
||||
if (!isNaN(index)) {
|
||||
(current as unknown as unknown[])[index] = value;
|
||||
} else {
|
||||
current[finalKey] = value;
|
||||
}
|
||||
|
||||
return newSpec;
|
||||
}
|
||||
|
||||
function findNodeById(
|
||||
spec: AtomizerSpec,
|
||||
nodeId: string
|
||||
): DesignVariable | Extractor | Objective | Constraint | null {
|
||||
// Check design variables
|
||||
const dv = spec.design_variables.find((d) => d.id === nodeId);
|
||||
if (dv) return dv;
|
||||
|
||||
// Check extractors
|
||||
const ext = spec.extractors.find((e) => e.id === nodeId);
|
||||
if (ext) return ext;
|
||||
|
||||
// Check objectives
|
||||
const obj = spec.objectives.find((o) => o.id === nodeId);
|
||||
if (obj) return obj;
|
||||
|
||||
// Check constraints
|
||||
const con = spec.constraints?.find((c) => c.id === nodeId);
|
||||
if (con) return con;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Store
|
||||
// ============================================================================
|
||||
|
||||
export const useSpecStore = create<SpecStore>()(
|
||||
devtools(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
// Initial state
|
||||
spec: null,
|
||||
studyId: null,
|
||||
hash: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
validation: null,
|
||||
selectedNodeId: null,
|
||||
selectedEdgeId: null,
|
||||
isDirty: false,
|
||||
pendingChanges: [],
|
||||
|
||||
// =====================================================================
|
||||
// Loading Actions
|
||||
// =====================================================================
|
||||
|
||||
loadSpec: async (studyId: string) => {
|
||||
set({ isLoading: true, error: null, studyId });
|
||||
|
||||
try {
|
||||
const { spec, hash } = await fetchSpec(studyId);
|
||||
set({
|
||||
spec,
|
||||
hash,
|
||||
isLoading: false,
|
||||
isDirty: false,
|
||||
pendingChanges: [],
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load spec',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
reloadSpec: async () => {
|
||||
const { studyId } = get();
|
||||
if (!studyId) return;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const { spec, hash } = await fetchSpec(studyId);
|
||||
set({
|
||||
spec,
|
||||
hash,
|
||||
isLoading: false,
|
||||
isDirty: false,
|
||||
pendingChanges: [],
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to reload spec',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearSpec: () => {
|
||||
set({
|
||||
spec: null,
|
||||
studyId: null,
|
||||
hash: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
validation: null,
|
||||
selectedNodeId: null,
|
||||
selectedEdgeId: null,
|
||||
isDirty: false,
|
||||
pendingChanges: [],
|
||||
});
|
||||
},
|
||||
|
||||
// Set spec directly from WebSocket (no API call)
|
||||
setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => {
|
||||
const currentStudyId = studyId || get().studyId;
|
||||
console.log('[useSpecStore] Setting spec from WebSocket:', spec.meta?.study_name);
|
||||
set({
|
||||
spec,
|
||||
studyId: currentStudyId,
|
||||
isLoading: false,
|
||||
isDirty: false,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Full Spec Operations
|
||||
// =====================================================================
|
||||
|
||||
saveSpec: async (spec: AtomizerSpec) => {
|
||||
const { studyId, hash } = get();
|
||||
if (!studyId) throw new Error('No study loaded');
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/studies/${studyId}/spec?modified_by=canvas${hash ? `&expected_hash=${hash}` : ''}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(spec),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Save failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
set({
|
||||
spec,
|
||||
hash: result.hash,
|
||||
isLoading: false,
|
||||
isDirty: false,
|
||||
pendingChanges: [],
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Save failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
replaceSpec: async (spec: AtomizerSpec) => {
|
||||
await get().saveSpec(spec);
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Patch Operations
|
||||
// =====================================================================
|
||||
|
||||
patchSpec: async (path: string, value: unknown) => {
|
||||
const { studyId, spec } = get();
|
||||
if (!studyId || !spec) throw new Error('No study loaded');
|
||||
|
||||
// Optimistic update
|
||||
const oldSpec = spec;
|
||||
const newSpec = applyPatchLocally(spec, path, value);
|
||||
set({ spec: newSpec, isDirty: true });
|
||||
|
||||
try {
|
||||
const result = await patchSpecApi(studyId, path, value);
|
||||
set({ hash: result.hash, isDirty: false });
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
set({ spec: oldSpec, isDirty: false });
|
||||
const message = error instanceof Error ? error.message : 'Patch failed';
|
||||
set({ error: message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
patchSpecOptimistic: (path: string, value: unknown) => {
|
||||
const { spec, studyId } = get();
|
||||
if (!spec) return;
|
||||
|
||||
// Apply locally immediately
|
||||
const newSpec = applyPatchLocally(spec, path, value);
|
||||
set({
|
||||
spec: newSpec,
|
||||
isDirty: true,
|
||||
pendingChanges: [...get().pendingChanges, { operation: 'set', path, value }],
|
||||
});
|
||||
|
||||
// Sync with backend (fire and forget, but handle errors)
|
||||
if (studyId) {
|
||||
patchSpecApi(studyId, path, value)
|
||||
.then((result) => {
|
||||
set({ hash: result.hash });
|
||||
// Remove from pending
|
||||
set({
|
||||
pendingChanges: get().pendingChanges.filter(
|
||||
(c) => !(c.path === path && c.value === value)
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Patch sync failed:', error);
|
||||
set({ error: error.message });
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Node Operations
|
||||
// =====================================================================
|
||||
|
||||
addNode: async (type, data) => {
|
||||
const { studyId } = get();
|
||||
if (!studyId) throw new Error('No study loaded');
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await addNodeApi(studyId, type, data);
|
||||
|
||||
// Reload spec to get new state
|
||||
await get().reloadSpec();
|
||||
|
||||
return result.node_id;
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Add node failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateNode: async (nodeId, updates) => {
|
||||
const { studyId } = get();
|
||||
if (!studyId) throw new Error('No study loaded');
|
||||
|
||||
try {
|
||||
await updateNodeApi(studyId, nodeId, updates);
|
||||
await get().reloadSpec();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Update failed';
|
||||
set({ error: message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
removeNode: async (nodeId) => {
|
||||
const { studyId } = get();
|
||||
if (!studyId) throw new Error('No study loaded');
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
await deleteNodeApi(studyId, nodeId);
|
||||
await get().reloadSpec();
|
||||
|
||||
// Clear selection if deleted node was selected
|
||||
if (get().selectedNodeId === nodeId) {
|
||||
set({ selectedNodeId: null });
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Delete failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateNodePosition: async (nodeId, position) => {
|
||||
const { studyId, spec } = get();
|
||||
if (!studyId || !spec) return;
|
||||
|
||||
// Find the node type and index
|
||||
let path: string | null = null;
|
||||
|
||||
const dvIndex = spec.design_variables.findIndex((d) => d.id === nodeId);
|
||||
if (dvIndex >= 0) {
|
||||
path = `design_variables[${dvIndex}].canvas_position`;
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
const extIndex = spec.extractors.findIndex((e) => e.id === nodeId);
|
||||
if (extIndex >= 0) {
|
||||
path = `extractors[${extIndex}].canvas_position`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
const objIndex = spec.objectives.findIndex((o) => o.id === nodeId);
|
||||
if (objIndex >= 0) {
|
||||
path = `objectives[${objIndex}].canvas_position`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!path && spec.constraints) {
|
||||
const conIndex = spec.constraints.findIndex((c) => c.id === nodeId);
|
||||
if (conIndex >= 0) {
|
||||
path = `constraints[${conIndex}].canvas_position`;
|
||||
}
|
||||
}
|
||||
|
||||
if (path) {
|
||||
// Use optimistic update for smooth dragging
|
||||
get().patchSpecOptimistic(path, position);
|
||||
}
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Edge Operations
|
||||
// =====================================================================
|
||||
|
||||
addEdge: async (source, target) => {
|
||||
const { studyId } = get();
|
||||
if (!studyId) throw new Error('No study loaded');
|
||||
|
||||
try {
|
||||
await addEdgeApi(studyId, source, target);
|
||||
await get().reloadSpec();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Add edge failed';
|
||||
set({ error: message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
removeEdge: async (source, target) => {
|
||||
const { studyId } = get();
|
||||
if (!studyId) throw new Error('No study loaded');
|
||||
|
||||
try {
|
||||
await removeEdgeApi(studyId, source, target);
|
||||
await get().reloadSpec();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Remove edge failed';
|
||||
set({ error: message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Custom Function
|
||||
// =====================================================================
|
||||
|
||||
addCustomFunction: async (name, code, outputs, description) => {
|
||||
const { studyId } = get();
|
||||
if (!studyId) throw new Error('No study loaded');
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await addCustomFunctionApi(studyId, name, code, outputs, description);
|
||||
await get().reloadSpec();
|
||||
return result.node_id;
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Add custom function failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Validation
|
||||
// =====================================================================
|
||||
|
||||
validateSpec: async () => {
|
||||
const { studyId } = get();
|
||||
if (!studyId) throw new Error('No study loaded');
|
||||
|
||||
try {
|
||||
const validation = await validateSpecApi(studyId);
|
||||
set({ validation });
|
||||
return validation;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Validation failed';
|
||||
set({ error: message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Selection
|
||||
// =====================================================================
|
||||
|
||||
selectNode: (nodeId) => {
|
||||
set({ selectedNodeId: nodeId, selectedEdgeId: null });
|
||||
},
|
||||
|
||||
selectEdge: (edgeId) => {
|
||||
set({ selectedEdgeId: edgeId, selectedNodeId: null });
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
set({ selectedNodeId: null, selectedEdgeId: null });
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Utility
|
||||
// =====================================================================
|
||||
|
||||
getNodeById: (nodeId) => {
|
||||
const { spec } = get();
|
||||
if (!spec) return null;
|
||||
return findNodeById(spec, nodeId);
|
||||
},
|
||||
|
||||
setError: (error) => {
|
||||
set({ error });
|
||||
},
|
||||
})),
|
||||
{ name: 'spec-store' }
|
||||
)
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Selector Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useSpec = () => useSpecStore((state) => state.spec);
|
||||
export const useSpecLoading = () => useSpecStore((state) => state.isLoading);
|
||||
export const useSpecError = () => useSpecStore((state) => state.error);
|
||||
export const useSpecValidation = () => useSpecStore((state) => state.validation);
|
||||
export const useSelectedNodeId = () => useSpecStore((state) => state.selectedNodeId);
|
||||
export const useSelectedEdgeId = () => useSpecStore((state) => state.selectedEdgeId);
|
||||
export const useSpecHash = () => useSpecStore((state) => state.hash);
|
||||
export const useSpecIsDirty = () => useSpecStore((state) => state.isDirty);
|
||||
|
||||
// Computed selectors
|
||||
export const useDesignVariables = () =>
|
||||
useSpecStore((state) => state.spec?.design_variables ?? []);
|
||||
export const useExtractors = () => useSpecStore((state) => state.spec?.extractors ?? []);
|
||||
export const useObjectives = () => useSpecStore((state) => state.spec?.objectives ?? []);
|
||||
export const useConstraints = () => useSpecStore((state) => state.spec?.constraints ?? []);
|
||||
export const useCanvasEdges = () => useSpecStore((state) => state.spec?.canvas?.edges ?? []);
|
||||
|
||||
export const useSelectedNode = () =>
|
||||
useSpecStore((state) => {
|
||||
if (!state.spec || !state.selectedNodeId) return null;
|
||||
return findNodeById(state.spec, state.selectedNodeId);
|
||||
});
|
||||
Reference in New Issue
Block a user