diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index de7b391f3..75a900a43 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -16,14 +16,14 @@ import { Skeleton } from '@/components/ui/skeleton'; import { type ComponentCategory, getCategorySeparatorColor } from '@/utils/categoryColors'; import { useThemeStore } from '@/store/themeStore'; import { useWorkflowUiStore } from '@/store/workflowUiStore'; +import { useWorkflowStore } from '@/store/workflowStore'; +import { usePlacementStore } from './sidebar-state'; // Use backend-provided category configuration // The frontend will no longer categorize components - it will use backend data type ViewMode = 'list' | 'tiles'; -import { mobilePlacementState } from './sidebar-state'; - interface ComponentItemProps { component: ComponentMetadata; disabled?: boolean; @@ -38,6 +38,11 @@ function ComponentItem({ component, disabled, viewMode }: ComponentItemProps) { const description = component.description || 'No description available yet.'; const [isSelected, setIsSelected] = useState(false); + // Get placement store and workflow ID for scoped placement + const setPlacement = usePlacementStore((state) => state.setPlacement); + const placementComponentId = usePlacementStore((state) => state.componentId); + const currentWorkflowId = useWorkflowStore((state) => state.metadata.id); + const onDragStart = (event: React.DragEvent) => { if (disabled) { event.preventDefault(); @@ -52,10 +57,8 @@ function ComponentItem({ component, disabled, viewMode }: ComponentItemProps) { const handleTap = () => { if (disabled || component.deprecated) return; - // Set the component for placement - mobilePlacementState.componentId = component.id; - mobilePlacementState.componentName = component.name; - mobilePlacementState.isActive = true; + // Set the component for placement, scoped to current workflow + setPlacement(component.id, component.name, currentWorkflowId); setIsSelected(true); // Close sidebar after a short delay to show selection feedback @@ -68,10 +71,10 @@ function ComponentItem({ component, disabled, viewMode }: ComponentItemProps) { // Clear selection if this component is no longer the active one useEffect(() => { - if (mobilePlacementState.componentId !== component.id) { + if (placementComponentId !== component.id) { setIsSelected(false); } - }, [component.id]); + }, [placementComponentId, component.id]); if (viewMode === 'list') { return ( diff --git a/frontend/src/components/layout/sidebar-state.ts b/frontend/src/components/layout/sidebar-state.ts index ff0b4512d..6fd70ffcd 100644 --- a/frontend/src/components/layout/sidebar-state.ts +++ b/frontend/src/components/layout/sidebar-state.ts @@ -1,19 +1,76 @@ -// Global state for mobile component placement (shared between Sidebar and Canvas) +import { create } from 'zustand'; + +interface PlacementState { + componentId: string | null; + componentName: string | null; + isActive: boolean; + // Scope placement to a specific workflow ID + workflowId: string | null; +} + +interface PlacementActions { + setPlacement: (componentId: string, componentName: string, workflowId: string | null) => void; + clearPlacement: () => void; + // Check if placement is active for a specific workflow + isPlacementActiveForWorkflow: (workflowId: string | null) => boolean; +} + +/** + * Component Placement Store + * Manages the state for placing components on the canvas via spotlight/sidebar. + * Placement is scoped to a specific workflow to avoid cross-workflow interference. + */ +export const usePlacementStore = create((set, get) => ({ + componentId: null, + componentName: null, + isActive: false, + workflowId: null, + + setPlacement: (componentId, componentName, workflowId) => { + set({ + componentId, + componentName, + isActive: true, + workflowId, + }); + }, + + clearPlacement: () => { + set({ + componentId: null, + componentName: null, + isActive: false, + workflowId: null, + }); + }, + + isPlacementActiveForWorkflow: (workflowId) => { + const state = get(); + // For new workflows (null ID), match if placement workflowId is also null + // For existing workflows, match by ID + return state.isActive && state.workflowId === workflowId; + }, +})); + +// Legacy exports for backward compatibility during migration +// TODO: Remove these after all usages are migrated to usePlacementStore export const mobilePlacementState = { - componentId: null as string | null, - componentName: null as string | null, - isActive: false, // True when a component is selected and waiting to be placed - onSidebarClose: null as (() => void) | null, // Callback to close sidebar + get componentId() { + return usePlacementStore.getState().componentId; + }, + get componentName() { + return usePlacementStore.getState().componentName; + }, + get isActive() { + return usePlacementStore.getState().isActive; + }, + onSidebarClose: null as (() => void) | null, }; -// Function to set the sidebar close callback export const setMobilePlacementSidebarClose = (callback: () => void) => { mobilePlacementState.onSidebarClose = callback; }; -// Function to clear the placement state export const clearMobilePlacement = () => { - mobilePlacementState.componentId = null; - mobilePlacementState.componentName = null; - mobilePlacementState.isActive = false; + usePlacementStore.getState().clearPlacement(); }; diff --git a/frontend/src/components/workflow/Canvas.tsx b/frontend/src/components/workflow/Canvas.tsx index 588e4210c..c3b6c92e6 100644 --- a/frontend/src/components/workflow/Canvas.tsx +++ b/frontend/src/components/workflow/Canvas.tsx @@ -41,7 +41,7 @@ import { useToast } from '@/components/ui/use-toast'; import type { WorkflowSchedule } from '@shipsec/shared'; import { cn } from '@/lib/utils'; import { useOptionalWorkflowSchedulesContext } from '@/features/workflow-builder/contexts/useWorkflowSchedulesContext'; -import { mobilePlacementState, clearMobilePlacement } from '@/components/layout/sidebar-state'; +import { usePlacementStore } from '@/components/layout/sidebar-state'; import { EntryPointActionsContext } from './entry-point-context'; // Custom hook to detect mobile viewport @@ -137,6 +137,10 @@ export function Canvas({ const { setConfigPanelOpen } = useWorkflowUiStore(); const isMobile = useIsMobile(); + // Component placement state (for spotlight/sidebar component placement) + const placementState = usePlacementStore(); + const isPlacementActive = placementState.isPlacementActiveForWorkflow(workflowId ?? null); + // Sync selection state with UI store for mobile bottom sheet visibility useEffect(() => { setConfigPanelOpen(Boolean(selectedNode)); @@ -595,7 +599,8 @@ export function Canvas({ if (mode !== 'design') return; // Check if there's a component selected for placement (mobile flow) - if (mobilePlacementState.isActive && mobilePlacementState.componentId) { + // Only place if the placement is scoped to this workflow + if (isPlacementActive && placementState.componentId) { let clientX: number; let clientY: number; @@ -612,16 +617,16 @@ export function Canvas({ } // Create node at tap position - createNodeFromComponent(mobilePlacementState.componentId, clientX, clientY); + createNodeFromComponent(placementState.componentId, clientX, clientY); // Clear placement state - clearMobilePlacement(); + placementState.clearPlacement(); event.preventDefault(); event.stopPropagation(); } }, - [createNodeFromComponent, mode], + [createNodeFromComponent, mode, isPlacementActive, placementState], ); // Handle node click for config panel @@ -671,11 +676,28 @@ export function Canvas({ [mode], ); - // Handle pane click to deselect - const onPaneClick = useCallback(() => { - hasUserInteractedRef.current = true; - setSelectedNode(null); - }, []); + // Handle pane click to deselect or place component + const onPaneClick = useCallback( + (event: React.MouseEvent) => { + hasUserInteractedRef.current = true; + + // Check if there's a component selected for placement (from spotlight/sidebar) + // Only place if the placement is scoped to this workflow + if (mode === 'design' && isPlacementActive && placementState.componentId) { + // Create node at click position + createNodeFromComponent(placementState.componentId, event.clientX, event.clientY); + + // Clear placement state + placementState.clearPlacement(); + + return; + } + + // Default behavior: deselect node + setSelectedNode(null); + }, + [mode, isPlacementActive, placementState, createNodeFromComponent], + ); // Handle validation dock node click - select and scroll to node const handleValidationNodeClick = useCallback( @@ -972,8 +994,8 @@ export function Canvas({ onClick={handleCanvasTap} onTouchEnd={handleCanvasTap} > - {/* Mobile placement indicator - shows when a component is selected */} - {mobilePlacementState.isActive && mobilePlacementState.componentName && ( + {/* Placement indicator - shows when a component is selected from spotlight/sidebar */} + {isPlacementActive && placementState.componentName && (
{/* Rotating border wrapper */}
- Tap to place:{' '} + Click to place:{' '} - {mobilePlacementState.componentName} + {placementState.componentName}