760 lines
23 KiB
TypeScript
760 lines
23 KiB
TypeScript
/**
|
|
* 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;
|
|
|
|
// Local draft integration (S2) - set spec locally (no API call) and mark dirty
|
|
setSpecLocalDraft: (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,
|
|
});
|
|
},
|
|
|
|
// Set spec locally as a draft (no API call). This is used by DraftManager (S2).
|
|
// Marks the spec as dirty to indicate "not published".
|
|
setSpecLocalDraft: (spec: AtomizerSpec, studyId?: string) => {
|
|
const currentStudyId = studyId || get().studyId;
|
|
console.log('[useSpecStore] Setting spec from local draft:', spec.meta?.study_name);
|
|
set({
|
|
spec,
|
|
studyId: currentStudyId,
|
|
isLoading: false,
|
|
isDirty: true,
|
|
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);
|
|
});
|