mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
Groups v0
This commit is contained in:
@@ -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({
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Block group actions */}
|
||||
{(canGroup || canUngroup) && <PopoverDivider />}
|
||||
{canGroup && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onGroupBlocks()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Group Blocks
|
||||
</PopoverItem>
|
||||
)}
|
||||
{canUngroup && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onUngroupBlocks()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Ungroup
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Single block actions */}
|
||||
{isSingleBlock && <PopoverDivider />}
|
||||
{isSingleBlock && !isSubflow && (
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
// 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<string>()
|
||||
|
||||
// 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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<string, any>
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ function captureWorkflowSnapshot(): WorkflowState {
|
||||
edges: rawState.edges || [],
|
||||
loops: rawState.loops || {},
|
||||
parallels: rawState.parallels || {},
|
||||
groups: rawState.groups || {},
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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 || {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ const initialState = {
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
groups: {},
|
||||
lastSaved: undefined,
|
||||
deploymentStatuses: {},
|
||||
needsRedeployment: false,
|
||||
@@ -577,6 +578,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
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<WorkflowStore>()(
|
||||
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<WorkflowStore>()(
|
||||
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<WorkflowStore>()(
|
||||
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' }
|
||||
)
|
||||
|
||||
@@ -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<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
/** Block groups for organizing blocks on the canvas */
|
||||
groups?: Record<string, BlockGroup>
|
||||
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<string, BlockGroup>
|
||||
}
|
||||
|
||||
export type WorkflowStore = WorkflowState & WorkflowActions
|
||||
|
||||
Reference in New Issue
Block a user