From f04cd7c355b8f20169e0f6c909af81b1fff811fb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 18:23:50 -0800 Subject: [PATCH] Groups v0 --- .../context-menu/block-context-menu.tsx | 35 +++++ .../components/context-menu/types.ts | 4 + .../hooks/use-canvas-context-menu.ts | 14 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 126 +++++++++++++-- apps/sim/hooks/use-collaborative-workflow.ts | 111 ++++++++++++++ apps/sim/hooks/use-undo-redo.ts | 144 ++++++++++++++++++ apps/sim/lib/workflows/diff/diff-engine.ts | 1 + apps/sim/socket/constants.ts | 4 + apps/sim/socket/database/operations.ts | 83 ++++++++++ apps/sim/socket/handlers/operations.ts | 64 ++++++++ apps/sim/socket/middleware/permissions.ts | 2 + apps/sim/socket/validation/schemas.ts | 26 ++++ apps/sim/stores/copilot-training/store.ts | 1 + apps/sim/stores/undo-redo/types.ts | 19 +++ apps/sim/stores/undo-redo/utils.ts | 26 ++++ apps/sim/stores/workflow-diff/utils.ts | 1 + apps/sim/stores/workflows/workflow/store.ts | 133 ++++++++++++++++ apps/sim/stores/workflows/workflow/types.ts | 43 ++++++ 18 files changed, 821 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx index 8945b13dc8..a7889a1227 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx @@ -29,6 +29,8 @@ export function BlockContextMenu({ onRemoveFromSubflow, onOpenEditor, onRename, + onGroupBlocks, + onUngroupBlocks, hasClipboard = false, showRemoveFromSubflow = false, disableEdit = false, @@ -47,6 +49,14 @@ export function BlockContextMenu({ const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock + // Check if we can group: need at least 2 blocks selected + const canGroup = selectedBlocks.length >= 2 + + // Check if we can ungroup: at least one selected block must be in a group + // Ungrouping will ungroup all blocks in that group (the entire group, not just selected blocks) + const hasGroupedBlock = selectedBlocks.some((b) => !!b.groupId) + const canUngroup = hasGroupedBlock + const getToggleEnabledLabel = () => { if (allEnabled) return 'Disable' if (allDisabled) return 'Enable' @@ -141,6 +151,31 @@ export function BlockContextMenu({ )} + {/* Block group actions */} + {(canGroup || canUngroup) && } + {canGroup && ( + { + onGroupBlocks() + onClose() + }} + > + Group Blocks + + )} + {canUngroup && ( + { + onUngroupBlocks() + onClose() + }} + > + Ungroup + + )} + {/* Single block actions */} {isSingleBlock && } {isSingleBlock && !isSubflow && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts index ed0ecd26ee..18990070f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts @@ -24,6 +24,8 @@ export interface ContextMenuBlockInfo { parentId?: string /** Parent type ('loop' | 'parallel') if nested */ parentType?: string + /** Group ID if block is in a group */ + groupId?: string } /** @@ -50,6 +52,8 @@ export interface BlockContextMenuProps { onRemoveFromSubflow: () => void onOpenEditor: () => void onRename: () => void + onGroupBlocks: () => void + onUngroupBlocks: () => void /** Whether clipboard has content for pasting */ hasClipboard?: boolean /** Whether remove from subflow option should be shown */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts index be4bc6bdfc..4b7961e1b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts @@ -35,6 +35,7 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP const block = blocks[n.id] const parentId = block?.data?.parentId const parentType = parentId ? blocks[parentId]?.type : undefined + const groupId = block?.data?.groupId return { id: n.id, type: block?.type || '', @@ -42,6 +43,7 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP horizontalHandles: block?.horizontalHandles ?? false, parentId, parentType, + groupId, } }), [blocks] @@ -49,14 +51,22 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP /** * Handle right-click on a node (block) + * If the node is part of a multiselection, include all selected nodes. + * If the node is not selected, just use that node. */ const handleNodeContextMenu = useCallback( (event: React.MouseEvent, node: Node) => { event.preventDefault() event.stopPropagation() - const selectedNodes = getNodes().filter((n) => n.selected) - const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node] + // Get all currently selected nodes + const allNodes = getNodes() + const selectedNodes = allNodes.filter((n) => n.selected) + + // If the right-clicked node is already selected, use all selected nodes + // Otherwise, just use the right-clicked node + const isNodeSelected = selectedNodes.some((n) => n.id === node.id) + const nodesToUse = isNodeSelected && selectedNodes.length > 0 ? selectedNodes : [node] setPosition({ x: event.clientX, y: event.clientY }) setSelectedBlocks(nodesToBlockInfos(nodesToUse)) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index f4e2b54883..6023127237 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -264,12 +264,14 @@ const WorkflowContent = React.memo(() => { const canUndo = undoRedoStack.undo.length > 0 const canRedo = undoRedoStack.redo.length > 0 - const { updateNodeDimensions, setDragStartPosition, getDragStartPosition } = useWorkflowStore( - useShallow((state) => ({ - updateNodeDimensions: state.updateNodeDimensions, - setDragStartPosition: state.setDragStartPosition, - getDragStartPosition: state.getDragStartPosition, - })) + const { updateNodeDimensions, setDragStartPosition, getDragStartPosition, getGroups } = + useWorkflowStore( + useShallow((state) => ({ + updateNodeDimensions: state.updateNodeDimensions, + setDragStartPosition: state.setDragStartPosition, + getDragStartPosition: state.getDragStartPosition, + getGroups: state.getGroups, + })) ) const copilotCleanup = useCopilotStore((state) => state.cleanup) @@ -458,6 +460,8 @@ const WorkflowContent = React.memo(() => { collaborativeBatchRemoveBlocks, collaborativeBatchToggleBlockEnabled, collaborativeBatchToggleBlockHandles, + collaborativeGroupBlocks, + collaborativeUngroupBlocks, undo, redo, } = useCollaborativeWorkflow() @@ -782,6 +786,21 @@ const WorkflowContent = React.memo(() => { collaborativeBatchToggleBlockHandles(blockIds) }, [contextMenuBlocks, collaborativeBatchToggleBlockHandles]) + const handleContextGroupBlocks = useCallback(() => { + const blockIds = contextMenuBlocks.map((block) => block.id) + if (blockIds.length >= 2) { + collaborativeGroupBlocks(blockIds) + } + }, [contextMenuBlocks, collaborativeGroupBlocks]) + + const handleContextUngroupBlocks = useCallback(() => { + // Find the first block with a groupId and ungroup that entire group + const groupedBlock = contextMenuBlocks.find((block) => block.groupId) + if (groupedBlock?.groupId) { + collaborativeUngroupBlocks(groupedBlock.groupId) + } + }, [contextMenuBlocks, collaborativeUngroupBlocks]) + const handleContextRemoveFromSubflow = useCallback(() => { const blocksToRemove = contextMenuBlocks.filter( (block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel') @@ -2060,16 +2079,56 @@ const WorkflowContent = React.memo(() => { window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) }, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent]) - /** Handles node changes - applies changes and resolves parent-child selection conflicts. */ + /** Handles node changes - applies changes and resolves parent-child selection conflicts. + * Also expands selection to include all group members when a grouped block is selected. + */ const onNodesChange = useCallback( (changes: NodeChange[]) => { setDisplayNodes((nds) => { - const updated = applyNodeChanges(changes, nds) + let updated = applyNodeChanges(changes, nds) const hasSelectionChange = changes.some((c) => c.type === 'select') - return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated + + if (hasSelectionChange) { + // Expand selection to include all group members + const groups = getGroups() + const selectedNodeIds = new Set(updated.filter((n) => n.selected).map((n) => n.id)) + const groupsToInclude = new Set() + + // Find all groups that have at least one selected member + selectedNodeIds.forEach((nodeId) => { + const groupId = blocks[nodeId]?.data?.groupId + if (groupId && groups[groupId]) { + groupsToInclude.add(groupId) + } + }) + + // Add all blocks from those groups to the selection + if (groupsToInclude.size > 0) { + const expandedNodeIds = new Set(selectedNodeIds) + groupsToInclude.forEach((groupId) => { + const group = groups[groupId] + if (group) { + group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId)) + } + }) + + // Update nodes to include expanded selection + if (expandedNodeIds.size > selectedNodeIds.size) { + updated = updated.map((n) => ({ + ...n, + selected: expandedNodeIds.has(n.id) ? true : n.selected, + })) + } + } + + // Resolve parent-child conflicts + updated = resolveParentChildSelectionConflicts(updated, blocks) + } + + return updated }) }, - [blocks] + [blocks, getGroups] ) /** @@ -3168,19 +3227,56 @@ const WorkflowContent = React.memo(() => { /** * Handles node click to select the node in ReactFlow. + * When clicking on a grouped block, also selects all other blocks in the group. * Parent-child conflict resolution happens automatically in onNodesChange. */ const handleNodeClick = useCallback( (event: React.MouseEvent, node: Node) => { const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey - setNodes((nodes) => - nodes.map((n) => ({ + const groups = getGroups() + + setNodes((nodes) => { + // First, calculate the base selection + let updatedNodes = nodes.map((n) => ({ ...n, selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id, })) - ) + + // Expand selection to include all group members + const selectedNodeIds = new Set(updatedNodes.filter((n) => n.selected).map((n) => n.id)) + const groupsToInclude = new Set() + + // Find all groups that have at least one selected member + selectedNodeIds.forEach((nodeId) => { + const groupId = blocks[nodeId]?.data?.groupId + if (groupId && groups[groupId]) { + groupsToInclude.add(groupId) + } + }) + + // Add all blocks from those groups to the selection + if (groupsToInclude.size > 0) { + const expandedNodeIds = new Set(selectedNodeIds) + groupsToInclude.forEach((groupId) => { + const group = groups[groupId] + if (group) { + group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId)) + } + }) + + // Update nodes with expanded selection + if (expandedNodeIds.size > selectedNodeIds.size) { + updatedNodes = updatedNodes.map((n) => ({ + ...n, + selected: expandedNodeIds.has(n.id) ? true : n.selected, + })) + } + } + + return updatedNodes + }) }, - [setNodes] + [setNodes, blocks, getGroups] ) /** Handles edge selection with container context tracking and Shift-click multi-selection. */ @@ -3415,6 +3511,8 @@ const WorkflowContent = React.memo(() => { onRemoveFromSubflow={handleContextRemoveFromSubflow} onOpenEditor={handleContextOpenEditor} onRename={handleContextRename} + onGroupBlocks={handleContextGroupBlocks} + onUngroupBlocks={handleContextUngroupBlocks} hasClipboard={hasClipboard()} showRemoveFromSubflow={contextMenuBlocks.some( (b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel') diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index c2fa032d86..8bde2d7952 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -424,6 +424,35 @@ export function useCollaborativeWorkflow() { logger.info('Successfully applied batch-update-parent from remote user') break } + case BLOCKS_OPERATIONS.GROUP_BLOCKS: { + const { blockIds, groupId } = payload + logger.info('Received group-blocks from remote user', { + userId, + groupId, + blockCount: (blockIds || []).length, + }) + + if (blockIds && blockIds.length > 0 && groupId) { + workflowStore.groupBlocks(blockIds, groupId) + } + + logger.info('Successfully applied group-blocks from remote user') + break + } + case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: { + const { groupId } = payload + logger.info('Received ungroup-blocks from remote user', { + userId, + groupId, + }) + + if (groupId) { + workflowStore.ungroupBlocks(groupId) + } + + logger.info('Successfully applied ungroup-blocks from remote user') + break + } } } } catch (error) { @@ -1584,6 +1613,84 @@ export function useCollaborativeWorkflow() { ] ) + const collaborativeGroupBlocks = useCallback( + (blockIds: string[]) => { + if (!isInActiveRoom()) { + logger.debug('Skipping group blocks - not in active workflow') + return null + } + + if (blockIds.length < 2) { + logger.debug('Cannot group fewer than 2 blocks') + return null + } + + const groupId = crypto.randomUUID() + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: BLOCKS_OPERATIONS.GROUP_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, + payload: { blockIds, groupId }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + workflowStore.groupBlocks(blockIds, groupId) + + undoRedo.recordGroupBlocks(blockIds, groupId) + + logger.info('Grouped blocks collaboratively', { groupId, blockCount: blockIds.length }) + return groupId + }, + [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo] + ) + + const collaborativeUngroupBlocks = useCallback( + (groupId: string) => { + if (!isInActiveRoom()) { + logger.debug('Skipping ungroup blocks - not in active workflow') + return [] + } + + const groups = workflowStore.getGroups() + const group = groups[groupId] + + if (!group) { + logger.warn('Cannot ungroup - group not found', { groupId }) + return [] + } + + const blockIds = [...group.blockIds] + const parentGroupId = group.parentGroupId + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, + payload: { groupId, blockIds, parentGroupId }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + workflowStore.ungroupBlocks(groupId) + + undoRedo.recordUngroupBlocks(groupId, blockIds, parentGroupId) + + logger.info('Ungrouped blocks collaboratively', { groupId, blockCount: blockIds.length }) + return blockIds + }, + [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo] + ) + return { // Connection status isConnected, @@ -1622,6 +1729,10 @@ export function useCollaborativeWorkflow() { collaborativeUpdateIterationCount, collaborativeUpdateIterationCollection, + // Collaborative block group operations + collaborativeGroupBlocks, + collaborativeUngroupBlocks, + // Direct access to stores for non-collaborative operations workflowStore, subBlockStore, diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 1bb6bf590c..7a48e8b91c 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -22,7 +22,9 @@ import { type BatchToggleHandlesOperation, type BatchUpdateParentOperation, createOperationEntry, + type GroupBlocksOperation, runWithUndoRedoRecordingSuspended, + type UngroupBlocksOperation, type UpdateParentOperation, useUndoRedoStore, } from '@/stores/undo-redo' @@ -874,6 +876,46 @@ export function useUndoRedo() { }) break } + case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: { + // Undoing group = ungroup (inverse is ungroup operation) + const inverseOp = entry.inverse as unknown as UngroupBlocksOperation + const { groupId } = inverseOp.data + + addToQueue({ + id: opId, + operation: { + operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, + payload: { groupId, blockIds: inverseOp.data.blockIds }, + }, + workflowId: activeWorkflowId, + userId, + }) + + workflowStore.ungroupBlocks(groupId) + logger.debug('Undid group blocks', { groupId }) + break + } + case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: { + // Undoing ungroup = re-group (inverse is group operation) + const inverseOp = entry.inverse as unknown as GroupBlocksOperation + const { groupId, blockIds } = inverseOp.data + + addToQueue({ + id: opId, + operation: { + operation: BLOCKS_OPERATIONS.GROUP_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, + payload: { groupId, blockIds }, + }, + workflowId: activeWorkflowId, + userId, + }) + + workflowStore.groupBlocks(blockIds, groupId) + logger.debug('Undid ungroup blocks', { groupId }) + break + } case UNDO_REDO_OPERATIONS.APPLY_DIFF: { const applyDiffInverse = entry.inverse as any const { baselineSnapshot } = applyDiffInverse.data @@ -1482,6 +1524,46 @@ export function useUndoRedo() { }) break } + case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: { + // Redo group = group again + const groupOp = entry.operation as GroupBlocksOperation + const { groupId, blockIds } = groupOp.data + + addToQueue({ + id: opId, + operation: { + operation: BLOCKS_OPERATIONS.GROUP_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, + payload: { groupId, blockIds }, + }, + workflowId: activeWorkflowId, + userId, + }) + + workflowStore.groupBlocks(blockIds, groupId) + logger.debug('Redid group blocks', { groupId }) + break + } + case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: { + // Redo ungroup = ungroup again + const ungroupOp = entry.operation as UngroupBlocksOperation + const { groupId } = ungroupOp.data + + addToQueue({ + id: opId, + operation: { + operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, + payload: { groupId, blockIds: ungroupOp.data.blockIds }, + }, + workflowId: activeWorkflowId, + userId, + }) + + workflowStore.ungroupBlocks(groupId) + logger.debug('Redid ungroup blocks', { groupId }) + break + } case UNDO_REDO_OPERATIONS.APPLY_DIFF: { // Redo apply-diff means re-applying the proposed state with diff markers const applyDiffOp = entry.operation as any @@ -1793,6 +1875,66 @@ export function useUndoRedo() { [activeWorkflowId, userId, undoRedoStore] ) + const recordGroupBlocks = useCallback( + (blockIds: string[], groupId: string) => { + if (!activeWorkflowId || blockIds.length === 0) return + + const operation: GroupBlocksOperation = { + id: crypto.randomUUID(), + type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { groupId, blockIds }, + } + + const inverse: UngroupBlocksOperation = { + id: crypto.randomUUID(), + type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { groupId, blockIds }, + } + + const entry = createOperationEntry(operation, inverse) + undoRedoStore.push(activeWorkflowId, userId, entry) + + logger.debug('Recorded group blocks', { groupId, blockCount: blockIds.length }) + }, + [activeWorkflowId, userId, undoRedoStore] + ) + + const recordUngroupBlocks = useCallback( + (groupId: string, blockIds: string[], parentGroupId?: string) => { + if (!activeWorkflowId || blockIds.length === 0) return + + const operation: UngroupBlocksOperation = { + id: crypto.randomUUID(), + type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { groupId, blockIds, parentGroupId }, + } + + const inverse: GroupBlocksOperation = { + id: crypto.randomUUID(), + type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { groupId, blockIds }, + } + + const entry = createOperationEntry(operation, inverse) + undoRedoStore.push(activeWorkflowId, userId, entry) + + logger.debug('Recorded ungroup blocks', { groupId, blockCount: blockIds.length }) + }, + [activeWorkflowId, userId, undoRedoStore] + ) + return { recordBatchAddBlocks, recordBatchRemoveBlocks, @@ -1806,6 +1948,8 @@ export function useUndoRedo() { recordApplyDiff, recordAcceptDiff, recordRejectDiff, + recordGroupBlocks, + recordUngroupBlocks, undo, redo, getStackSizes, diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index f22365d145..9b74e4e5a4 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -1174,5 +1174,6 @@ export function stripWorkflowDiffMarkers(state: WorkflowState): WorkflowState { edges: structuredClone(state.edges || []), loops: structuredClone(state.loops || {}), parallels: structuredClone(state.parallels || {}), + groups: structuredClone(state.groups || {}), } } diff --git a/apps/sim/socket/constants.ts b/apps/sim/socket/constants.ts index 98f49d846e..7a703e74f9 100644 --- a/apps/sim/socket/constants.ts +++ b/apps/sim/socket/constants.ts @@ -16,6 +16,8 @@ export const BLOCKS_OPERATIONS = { BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled', BATCH_TOGGLE_HANDLES: 'batch-toggle-handles', BATCH_UPDATE_PARENT: 'batch-update-parent', + GROUP_BLOCKS: 'group-blocks', + UNGROUP_BLOCKS: 'ungroup-blocks', } as const export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS] @@ -87,6 +89,8 @@ export const UNDO_REDO_OPERATIONS = { APPLY_DIFF: 'apply-diff', ACCEPT_DIFF: 'accept-diff', REJECT_DIFF: 'reject-diff', + GROUP_BLOCKS: 'group-blocks', + UNGROUP_BLOCKS: 'ungroup-blocks', } as const export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS] diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index 1f52d46ef9..a6bd24f8b7 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -810,6 +810,89 @@ async function handleBlocksOperationTx( break } + case BLOCKS_OPERATIONS.GROUP_BLOCKS: { + const { blockIds, groupId } = payload + if (!Array.isArray(blockIds) || blockIds.length === 0 || !groupId) { + logger.debug('Invalid payload for group blocks operation') + return + } + + logger.info(`Grouping ${blockIds.length} blocks into group ${groupId} in workflow ${workflowId}`) + + for (const blockId of blockIds) { + const [currentBlock] = await tx + .select({ data: workflowBlocks.data }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (!currentBlock) { + logger.warn(`Block ${blockId} not found for grouping`) + continue + } + + const currentData = currentBlock?.data || {} + const updatedData = { ...currentData, groupId } + + await tx + .update(workflowBlocks) + .set({ + data: updatedData, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + } + + logger.debug(`Grouped ${blockIds.length} blocks into group ${groupId}`) + break + } + + case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: { + const { groupId, blockIds, parentGroupId } = payload + if (!groupId || !Array.isArray(blockIds)) { + logger.debug('Invalid payload for ungroup blocks operation') + return + } + + logger.info(`Ungrouping ${blockIds.length} blocks from group ${groupId} in workflow ${workflowId}`) + + for (const blockId of blockIds) { + const [currentBlock] = await tx + .select({ data: workflowBlocks.data }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (!currentBlock) { + logger.warn(`Block ${blockId} not found for ungrouping`) + continue + } + + const currentData = currentBlock?.data || {} + let updatedData: Record + + if (parentGroupId) { + // Move to parent group + updatedData = { ...currentData, groupId: parentGroupId } + } else { + // Remove from group entirely + const { groupId: _removed, ...restData } = currentData + updatedData = restData + } + + await tx + .update(workflowBlocks) + .set({ + data: updatedData, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + } + + logger.debug(`Ungrouped ${blockIds.length} blocks from group ${groupId}`) + break + } + default: throw new Error(`Unsupported blocks operation: ${operation}`) } diff --git a/apps/sim/socket/handlers/operations.ts b/apps/sim/socket/handlers/operations.ts index 9b74293bbf..026a683694 100644 --- a/apps/sim/socket/handlers/operations.ts +++ b/apps/sim/socket/handlers/operations.ts @@ -465,6 +465,70 @@ export function setupOperationsHandlers( return } + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.GROUP_BLOCKS + ) { + await persistWorkflowOperation(workflowId, { + operation, + target, + payload, + timestamp: operationTimestamp, + userId: session.userId, + }) + + room.lastModified = Date.now() + + socket.to(workflowId).emit('workflow-operation', { + operation, + target, + payload, + timestamp: operationTimestamp, + senderId: socket.id, + userId: session.userId, + userName: session.userName, + metadata: { workflowId, operationId: crypto.randomUUID() }, + }) + + if (operationId) { + socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() }) + } + + return + } + + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.UNGROUP_BLOCKS + ) { + await persistWorkflowOperation(workflowId, { + operation, + target, + payload, + timestamp: operationTimestamp, + userId: session.userId, + }) + + room.lastModified = Date.now() + + socket.to(workflowId).emit('workflow-operation', { + operation, + target, + payload, + timestamp: operationTimestamp, + senderId: socket.id, + userId: session.userId, + userName: session.userName, + metadata: { workflowId, operationId: crypto.randomUUID() }, + }) + + if (operationId) { + socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() }) + } + + return + } + if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_ADD_EDGES) { await persistWorkflowOperation(workflowId, { operation, diff --git a/apps/sim/socket/middleware/permissions.ts b/apps/sim/socket/middleware/permissions.ts index 1ff6b09e8b..b9f6139c1d 100644 --- a/apps/sim/socket/middleware/permissions.ts +++ b/apps/sim/socket/middleware/permissions.ts @@ -30,6 +30,8 @@ const WRITE_OPERATIONS: string[] = [ BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES, BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT, + BLOCKS_OPERATIONS.GROUP_BLOCKS, + BLOCKS_OPERATIONS.UNGROUP_BLOCKS, // Edge operations EDGE_OPERATIONS.ADD, EDGE_OPERATIONS.REMOVE, diff --git a/apps/sim/socket/validation/schemas.ts b/apps/sim/socket/validation/schemas.ts index 395b321028..236c3583ce 100644 --- a/apps/sim/socket/validation/schemas.ts +++ b/apps/sim/socket/validation/schemas.ts @@ -221,6 +221,30 @@ export const BatchUpdateParentSchema = z.object({ operationId: z.string().optional(), }) +export const GroupBlocksSchema = z.object({ + operation: z.literal(BLOCKS_OPERATIONS.GROUP_BLOCKS), + target: z.literal(OPERATION_TARGETS.BLOCKS), + payload: z.object({ + blockIds: z.array(z.string()), + groupId: z.string(), + name: z.string().optional(), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const UngroupBlocksSchema = z.object({ + operation: z.literal(BLOCKS_OPERATIONS.UNGROUP_BLOCKS), + target: z.literal(OPERATION_TARGETS.BLOCKS), + payload: z.object({ + groupId: z.string(), + blockIds: z.array(z.string()), + parentGroupId: z.string().optional(), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + export const WorkflowOperationSchema = z.union([ BlockOperationSchema, BatchPositionUpdateSchema, @@ -229,6 +253,8 @@ export const WorkflowOperationSchema = z.union([ BatchToggleEnabledSchema, BatchToggleHandlesSchema, BatchUpdateParentSchema, + GroupBlocksSchema, + UngroupBlocksSchema, EdgeOperationSchema, BatchAddEdgesSchema, BatchRemoveEdgesSchema, diff --git a/apps/sim/stores/copilot-training/store.ts b/apps/sim/stores/copilot-training/store.ts index fc6a346769..b4e5121f2b 100644 --- a/apps/sim/stores/copilot-training/store.ts +++ b/apps/sim/stores/copilot-training/store.ts @@ -25,6 +25,7 @@ function captureWorkflowSnapshot(): WorkflowState { edges: rawState.edges || [], loops: rawState.loops || {}, parallels: rawState.parallels || {}, + groups: rawState.groups || {}, lastSaved: Date.now(), } } diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index f68aa66e68..70cd429cad 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -126,6 +126,23 @@ export interface RejectDiffOperation extends BaseOperation { } } +export interface GroupBlocksOperation extends BaseOperation { + type: typeof UNDO_REDO_OPERATIONS.GROUP_BLOCKS + data: { + groupId: string + blockIds: string[] + } +} + +export interface UngroupBlocksOperation extends BaseOperation { + type: typeof UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS + data: { + groupId: string + blockIds: string[] + parentGroupId?: string + } +} + export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation @@ -139,6 +156,8 @@ export type Operation = | ApplyDiffOperation | AcceptDiffOperation | RejectDiffOperation + | GroupBlocksOperation + | UngroupBlocksOperation export interface OperationEntry { id: string diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index e747c2fd2d..e38a9215d5 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -6,8 +6,10 @@ import type { BatchRemoveBlocksOperation, BatchRemoveEdgesOperation, BatchUpdateParentOperation, + GroupBlocksOperation, Operation, OperationEntry, + UngroupBlocksOperation, } from '@/stores/undo-redo/types' export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry { @@ -164,6 +166,30 @@ export function createInverseOperation(operation: Operation): Operation { }, } + case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: { + const op = operation as GroupBlocksOperation + return { + ...operation, + type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS, + data: { + groupId: op.data.groupId, + blockIds: op.data.blockIds, + }, + } as UngroupBlocksOperation + } + + case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: { + const op = operation as UngroupBlocksOperation + return { + ...operation, + type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS, + data: { + groupId: op.data.groupId, + blockIds: op.data.blockIds, + }, + } as GroupBlocksOperation + } + default: { const exhaustiveCheck: never = operation throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) diff --git a/apps/sim/stores/workflow-diff/utils.ts b/apps/sim/stores/workflow-diff/utils.ts index 3245875f77..464dbceaee 100644 --- a/apps/sim/stores/workflow-diff/utils.ts +++ b/apps/sim/stores/workflow-diff/utils.ts @@ -16,6 +16,7 @@ export function cloneWorkflowState(state: WorkflowState): WorkflowState { edges: structuredClone(state.edges || []), loops: structuredClone(state.loops || {}), parallels: structuredClone(state.parallels || {}), + groups: structuredClone(state.groups || {}), } } diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 398c662812..7f5e1398c7 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -95,6 +95,7 @@ const initialState = { edges: [], loops: {}, parallels: {}, + groups: {}, lastSaved: undefined, deploymentStatuses: {}, needsRedeployment: false, @@ -577,6 +578,7 @@ export const useWorkflowStore = create()( edges: state.edges, loops: state.loops, parallels: state.parallels, + groups: state.groups, lastSaved: state.lastSaved, deploymentStatuses: state.deploymentStatuses, needsRedeployment: state.needsRedeployment, @@ -597,6 +599,7 @@ export const useWorkflowStore = create()( Object.keys(workflowState.parallels || {}).length > 0 ? workflowState.parallels : generateParallelBlocks(nextBlocks) + const nextGroups = workflowState.groups || state.groups return { ...state, @@ -604,6 +607,7 @@ export const useWorkflowStore = create()( edges: nextEdges, loops: nextLoops, parallels: nextParallels, + groups: nextGroups, deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses, needsRedeployment: workflowState.needsRedeployment !== undefined @@ -1333,6 +1337,135 @@ export const useWorkflowStore = create()( getDragStartPosition: () => { return get().dragStartPosition || null }, + + groupBlocks: (blockIds: string[], groupId?: string) => { + if (blockIds.length === 0) return '' + + const newGroupId = groupId || crypto.randomUUID() + const currentGroups = get().groups || {} + const currentBlocks = get().blocks + + // Remove blocks from any existing groups they might be in + const updatedGroups = { ...currentGroups } + for (const gId of Object.keys(updatedGroups)) { + updatedGroups[gId] = { + ...updatedGroups[gId], + blockIds: updatedGroups[gId].blockIds.filter((bid) => !blockIds.includes(bid)), + } + // Remove empty groups + if (updatedGroups[gId].blockIds.length === 0) { + delete updatedGroups[gId] + } + } + + // Create the new group + updatedGroups[newGroupId] = { + id: newGroupId, + blockIds: [...blockIds], + } + + // Update blocks with the new groupId + const newBlocks = { ...currentBlocks } + for (const blockId of blockIds) { + if (newBlocks[blockId]) { + newBlocks[blockId] = { + ...newBlocks[blockId], + data: { + ...newBlocks[blockId].data, + groupId: newGroupId, + }, + } + } + } + + set({ + blocks: newBlocks, + groups: updatedGroups, + }) + + get().updateLastSaved() + logger.info('Created block group', { groupId: newGroupId, blockCount: blockIds.length }) + return newGroupId + }, + + ungroupBlocks: (groupId: string) => { + const currentGroups = get().groups || {} + const currentBlocks = get().blocks + const group = currentGroups[groupId] + + if (!group) { + logger.warn('Attempted to ungroup non-existent group', { groupId }) + return [] + } + + const blockIds = [...group.blockIds] + const parentGroupId = group.parentGroupId + + // Remove the group + const updatedGroups = { ...currentGroups } + delete updatedGroups[groupId] + + // Update blocks - remove groupId or assign to parent group + const newBlocks = { ...currentBlocks } + for (const blockId of blockIds) { + if (newBlocks[blockId]) { + const newData = { ...newBlocks[blockId].data } + if (parentGroupId) { + newData.groupId = parentGroupId + } else { + delete newData.groupId + } + newBlocks[blockId] = { + ...newBlocks[blockId], + data: newData, + } + } + } + + // If there's a parent group, add the blocks to it + if (parentGroupId && updatedGroups[parentGroupId]) { + updatedGroups[parentGroupId] = { + ...updatedGroups[parentGroupId], + blockIds: [...updatedGroups[parentGroupId].blockIds, ...blockIds], + } + } + + set({ + blocks: newBlocks, + groups: updatedGroups, + }) + + get().updateLastSaved() + logger.info('Ungrouped blocks', { groupId, blockCount: blockIds.length }) + return blockIds + }, + + getGroupBlockIds: (groupId: string, recursive = false) => { + const groups = get().groups || {} + const group = groups[groupId] + + if (!group) return [] + + if (!recursive) { + return [...group.blockIds] + } + + // Recursively get all block IDs, including from nested groups + const allBlockIds: string[] = [...group.blockIds] + + // Find child groups (groups whose parentGroupId is this group) + for (const g of Object.values(groups)) { + if (g.parentGroupId === groupId) { + allBlockIds.push(...get().getGroupBlockIds(g.id, true)) + } + } + + return allBlockIds + }, + + getGroups: () => { + return get().groups || {} + }, }), { name: 'workflow-store' } ) diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 43afa31a66..210aba943e 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -63,6 +63,9 @@ export interface BlockData { // Container node type (for ReactFlow node type determination) type?: string + + // Block group membership + groupId?: string } export interface BlockLayoutState { @@ -144,6 +147,22 @@ export interface Variable { value: unknown } +/** + * Represents a group of blocks on the canvas. + * Groups can be nested (a group can contain other groups via block membership). + * When a block is in a group, it stores the groupId in its data. + */ +export interface BlockGroup { + /** Unique identifier for the group */ + id: string + /** Optional display name for the group */ + name?: string + /** Block IDs that are direct members of this group */ + blockIds: string[] + /** Parent group ID if this group is nested inside another group */ + parentGroupId?: string +} + export interface DragStartPosition { id: string x: number @@ -157,6 +176,8 @@ export interface WorkflowState { lastSaved?: number loops: Record parallels: Record + /** Block groups for organizing blocks on the canvas */ + groups?: Record lastUpdate?: number metadata?: { name?: string @@ -243,6 +264,28 @@ export interface WorkflowActions { workflowState: WorkflowState, options?: { updateLastSaved?: boolean } ) => void + + // Block group operations + /** + * Groups the specified blocks together. + * If any blocks are already in a group, they are removed from their current group first. + * @returns The new group ID + */ + groupBlocks: (blockIds: string[], groupId?: string) => string + /** + * Ungroups a group, removing it and releasing its blocks. + * If the group has a parent group, blocks are moved to the parent group. + * @returns The block IDs that were in the group + */ + ungroupBlocks: (groupId: string) => string[] + /** + * Gets all block IDs in a group, including blocks in nested groups (recursive). + */ + getGroupBlockIds: (groupId: string, recursive?: boolean) => string[] + /** + * Gets all groups in the workflow. + */ + getGroups: () => Record } export type WorkflowStore = WorkflowState & WorkflowActions