Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(canvas): added the ability to lock blocks#3102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Uh oh!
There was an error while loading. Please reload this page.
Merged
Changes from all commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
d533ea2
feat(canvas): added the ability to lock blocks
waleedlatif1 6907c88
unlock duplicates of locked blocks
waleedlatif1 4f342d3
Merge origin/staging into feat/lock
waleedlatif1 63eba0f
fix(duplicate): place duplicate outside locked container
waleedlatif1 c19263e
fix(duplicate): unlock all blocks when duplicating workflow
waleedlatif1 3f908d6
fix code block disabled state, allow unlock from editor
waleedlatif1 73856af
fix(lock): address code review feedback
waleedlatif1 bd36283
fix(lock): prevent unlocking blocks inside locked containers
waleedlatif1 ecb13a5
fix(lock): ensure consistent behavior across all UIs
waleedlatif1 da5e0aa
fix(enable): consistent behavior - can't enable if parent disabled
waleedlatif1 ee9f2e3
docs(quick-reference): add lock block action
waleedlatif1 ab4b09c
remove prefix square brackets in error notif
waleedlatif1 7714dad
add lock block image
waleedlatif1 901bffe
fix(block-menu): paste should not be disabled for locked selection
waleedlatif1 c987b6f
refactor(workflow): extract block deletion protection into shared uti…
waleedlatif1 8dad4d4
refactor(workflow): extend block protection utilities for edge protec…
waleedlatif1 4c05ae1
fix(lock): address review comments for lock feature
waleedlatif1 802884f
fix(copilot): add lock checks for insert and extract operations
waleedlatif1 0eea69b
fix(lock): prevent duplicates inside locked containers via regenerate…
waleedlatif1 395e6ed
fix(lock): fix toggle locked target state and draggable check
waleedlatif1 ef4acfd
fix(copilot): check parent lock in edit and delete operations
waleedlatif1 3664a56
fix(socket): add server-side lock validation and admin-only permissions
waleedlatif1 3fbcfc6
test(socket): update permission test for admin-only lock toggle
waleedlatif1 52d9f31
fix(undo-redo): use consistent target state for toggle redo
waleedlatif1 813ec9b
fix(socket): add comprehensive lock validation across operations
waleedlatif1 a826b97
refactor(workflow): use pre-computed lock state from contextMenuBlocks
waleedlatif1 36c4b22
Merge origin/staging and remove unused hasProtectedBlocks
waleedlatif1 6a1c52b
fix(lock): add lock validation to block rename operations
waleedlatif1 0cc0be7
added defense in depth for renaming locked blocks
waleedlatif1 104a828
fix(socket): add server-side lock validation for edges and subblocks
waleedlatif1 d6d165a
fix(lock): fetch parent blocks for edge protection checks and consist…
waleedlatif1 e5d078b
updated tooltip text for run from block
waleedlatif1 1bb3a71
fix(lock): add lock check to duplicate button and clean up drag handler
waleedlatif1 6f98911
fix(lock): use disableEdit for duplicate in block menu
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
125 changes: 94 additions & 31 deletions
125 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| import { memo, useCallback } from 'react' | ||
| import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react' | ||
| import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react' | ||
| import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn' | ||
| import { cn } from '@/lib/core/utils/cn' | ||
| import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' | ||
| @@ -49,6 +49,7 @@ export const ActionBar = memo( | ||
| collaborativeBatchRemoveBlocks, | ||
| collaborativeBatchToggleBlockEnabled, | ||
| collaborativeBatchToggleBlockHandles, | ||
| collaborativeBatchToggleLocked, | ||
| } = useCollaborativeWorkflow() | ||
| const { setPendingSelection } = useWorkflowRegistry() | ||
| const { handleRunFromBlock } = useWorkflowExecution() | ||
| @@ -84,16 +85,28 @@ export const ActionBar = memo( | ||
| ) | ||
| }, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection]) | ||
| const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore( | ||
| const { | ||
| isEnabled, | ||
| horizontalHandles, | ||
| parentId, | ||
| parentType, | ||
| isLocked, | ||
| isParentLocked, | ||
| isParentDisabled, | ||
| } = useWorkflowStore( | ||
| useCallback( | ||
| (state) => { | ||
| const block = state.blocks[blockId] | ||
| const parentId = block?.data?.parentId | ||
| const parentBlock = parentId ? state.blocks[parentId] : undefined | ||
| return { | ||
| isEnabled: block?.enabled ?? true, | ||
| horizontalHandles: block?.horizontalHandles ?? false, | ||
| parentId, | ||
| parentType: parentId ? state.blocks[parentId]?.type : undefined, | ||
| parentType: parentBlock?.type, | ||
| isLocked: block?.locked ?? false, | ||
| isParentLocked: parentBlock?.locked ?? false, | ||
| isParentDisabled: parentBlock ? !parentBlock.enabled : false, | ||
| } | ||
| }, | ||
| [blockId] | ||
| @@ -159,52 +172,90 @@ export const ActionBar = memo( | ||
| )} | ||
| > | ||
| {!isNoteBlock && !isInsideSubflow && ( | ||
| <Tooltip.Root> | ||
| <Tooltip.Trigger asChild> | ||
| <span className='inline-flex'> | ||
| <Button | ||
| variant='ghost' | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| if (canRunFromBlock && !disabled) { | ||
| handleRunFromBlockClick() | ||
| } | ||
| }} | ||
| className={ACTION_BUTTON_STYLES} | ||
| disabled={disabled || !canRunFromBlock} | ||
| > | ||
| <PlayOutline className={ICON_SIZE} /> | ||
| </Button> | ||
| </span> | ||
| </Tooltip.Trigger> | ||
| <Tooltip.Content side='top'> | ||
| {(() => { | ||
| if (disabled) return getTooltipMessage('Run from block') | ||
| if (isExecuting) return 'Execution in progress' | ||
| if (!dependenciesSatisfied) return 'Run previous blocks first' | ||
| return 'Run from block' | ||
| })()} | ||
| </Tooltip.Content> | ||
| </Tooltip.Root> | ||
| )} | ||
| {!isNoteBlock && ( | ||
| <Tooltip.Root> | ||
| <Tooltip.Trigger asChild> | ||
| <Button | ||
| variant='ghost' | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| if (canRunFromBlock && !disabled) { | ||
| handleRunFromBlockClick() | ||
| // Can't enable if parent is disabled (must enable parent first) | ||
| const cantEnable = !isEnabled && isParentDisabled | ||
| if (!disabled && !isLocked && !isParentLocked && !cantEnable) { | ||
| collaborativeBatchToggleBlockEnabled([blockId]) | ||
| } | ||
| }} | ||
| className={ACTION_BUTTON_STYLES} | ||
| disabled={disabled || !canRunFromBlock} | ||
| disabled={ | ||
| disabled || isLocked || isParentLocked || (!isEnabled && isParentDisabled) | ||
| } | ||
| > | ||
| <PlayOutline className={ICON_SIZE} /> | ||
| {isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />} | ||
| </Button> | ||
| </Tooltip.Trigger> | ||
| <Tooltip.Content side='top'> | ||
| {(() => { | ||
| if (disabled) return getTooltipMessage('Run from block') | ||
| if (isExecuting) return 'Execution in progress' | ||
| if (!dependenciesSatisfied) return 'Run upstream blocks first' | ||
| return 'Run from block' | ||
| })()} | ||
| {isLocked || isParentLocked | ||
| ? 'Block is locked' | ||
| : !isEnabled && isParentDisabled | ||
| ? 'Parent container is disabled' | ||
| : getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} | ||
| </Tooltip.Content> | ||
| </Tooltip.Root> | ||
| )} | ||
| {!isNoteBlock && ( | ||
| {userPermissions.canAdmin && ( | ||
| <Tooltip.Root> | ||
| <Tooltip.Trigger asChild> | ||
| <Button | ||
| variant='ghost' | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| if (!disabled) { | ||
| collaborativeBatchToggleBlockEnabled([blockId]) | ||
| // Can't unlock a block if its parent container is locked | ||
| if (!disabled && !(isLocked && isParentLocked)) { | ||
| collaborativeBatchToggleLocked([blockId]) | ||
| } | ||
| }} | ||
| className={ACTION_BUTTON_STYLES} | ||
| disabled={disabled} | ||
| disabled={disabled || (isLocked && isParentLocked)} | ||
| > | ||
| {isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />} | ||
| {isLocked ? <Unlock className={ICON_SIZE} /> : <Lock className={ICON_SIZE} />} | ||
| </Button> | ||
| </Tooltip.Trigger> | ||
| <Tooltip.Content side='top'> | ||
| {getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} | ||
| {isLocked && isParentLocked | ||
| ? 'Parent container is locked' | ||
| : isLocked | ||
| ? 'Unlock Block' | ||
| : 'Lock Block'} | ||
| </Tooltip.Content> | ||
| </Tooltip.Root> | ||
| )} | ||
| @@ -216,17 +267,21 @@ export const ActionBar = memo( | ||
| variant='ghost' | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| if (!disabled) { | ||
| if (!disabled && !isLocked && !isParentLocked) { | ||
| handleDuplicateBlock() | ||
| } | ||
| }} | ||
| className={ACTION_BUTTON_STYLES} | ||
| disabled={disabled} | ||
| disabled={disabled || isLocked || isParentLocked} | ||
| > | ||
| <Copy className={ICON_SIZE} /> | ||
| </Button> | ||
| </Tooltip.Trigger> | ||
| <Tooltip.Content side='top'>{getTooltipMessage('Duplicate Block')}</Tooltip.Content> | ||
| <Tooltip.Content side='top'> | ||
| {isLocked || isParentLocked | ||
| ? 'Block is locked' | ||
| : getTooltipMessage('Duplicate Block')} | ||
| </Tooltip.Content> | ||
| </Tooltip.Root> | ||
| )} | ||
| @@ -237,12 +292,12 @@ export const ActionBar = memo( | ||
| variant='ghost' | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| if (!disabled) { | ||
| if (!disabled && !isLocked && !isParentLocked) { | ||
| collaborativeBatchToggleBlockHandles([blockId]) | ||
| } | ||
| }} | ||
| className={ACTION_BUTTON_STYLES} | ||
| disabled={disabled} | ||
| disabled={disabled || isLocked || isParentLocked} | ||
| > | ||
| {horizontalHandles ? ( | ||
| <ArrowLeftRight className={ICON_SIZE} /> | ||
| @@ -252,7 +307,9 @@ export const ActionBar = memo( | ||
| </Button> | ||
| </Tooltip.Trigger> | ||
| <Tooltip.Content side='top'> | ||
| {getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')} | ||
| {isLocked || isParentLocked | ||
| ? 'Block is locked' | ||
| : getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')} | ||
| </Tooltip.Content> | ||
| </Tooltip.Root> | ||
| )} | ||
| @@ -264,19 +321,23 @@ export const ActionBar = memo( | ||
| variant='ghost' | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| if (!disabled && userPermissions.canEdit) { | ||
| if (!disabled && userPermissions.canEdit && !isLocked && !isParentLocked) { | ||
| window.dispatchEvent( | ||
| new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } }) | ||
| ) | ||
| } | ||
| }} | ||
| className={ACTION_BUTTON_STYLES} | ||
| disabled={disabled || !userPermissions.canEdit} | ||
| disabled={disabled || !userPermissions.canEdit || isLocked || isParentLocked} | ||
| > | ||
| <LogOut className={ICON_SIZE} /> | ||
| </Button> | ||
| </Tooltip.Trigger> | ||
| <Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content> | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| <Tooltip.Content side='top'> | ||
| {isLocked || isParentLocked | ||
| ? 'Block is locked' | ||
| : getTooltipMessage('Remove from Subflow')} | ||
| </Tooltip.Content> | ||
| </Tooltip.Root> | ||
| )} | ||
| @@ -286,17 +347,19 @@ export const ActionBar = memo( | ||
| variant='ghost' | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| if (!disabled) { | ||
| if (!disabled && !isLocked && !isParentLocked) { | ||
| collaborativeBatchRemoveBlocks([blockId]) | ||
| } | ||
| }} | ||
| className={ACTION_BUTTON_STYLES} | ||
| disabled={disabled} | ||
| disabled={disabled || isLocked || isParentLocked} | ||
| > | ||
| <Trash2 className={ICON_SIZE} /> | ||
| </Button> | ||
| </Tooltip.Trigger> | ||
| <Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content> | ||
| <Tooltip.Content side='top'> | ||
| {isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')} | ||
| </Tooltip.Content> | ||
| </Tooltip.Root> | ||
| </div> | ||
| ) | ||
50 changes: 45 additions & 5 deletions
50 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -20,6 +20,9 @@ export interface BlockInfo { | ||
| horizontalHandles: boolean | ||
| parentId?: string | ||
| parentType?: string | ||
| locked?: boolean | ||
| isParentLocked?: boolean | ||
| isParentDisabled?: boolean | ||
| } | ||
| /** | ||
| @@ -46,10 +49,17 @@ export interface BlockMenuProps { | ||
| showRemoveFromSubflow?: boolean | ||
| /** Whether run from block is available (has snapshot, was executed, not inside subflow) */ | ||
| canRunFromBlock?: boolean | ||
| /** Whether to disable edit actions (user can't edit OR blocks are locked) */ | ||
| disableEdit?: boolean | ||
| /** Whether the user has edit permission (ignoring locked state) */ | ||
| userCanEdit?: boolean | ||
| isExecuting?: boolean | ||
| /** Whether the selected block is a trigger (has no incoming edges) */ | ||
| isPositionalTrigger?: boolean | ||
| /** Callback to toggle locked state of selected blocks */ | ||
| onToggleLocked?: () => void | ||
| /** Whether the user has admin permissions */ | ||
| canAdmin?: boolean | ||
| } | ||
| /** | ||
| @@ -78,13 +88,22 @@ export function BlockMenu({ | ||
| showRemoveFromSubflow = false, | ||
| canRunFromBlock = false, | ||
| disableEdit = false, | ||
| userCanEdit = true, | ||
| isExecuting = false, | ||
| isPositionalTrigger = false, | ||
| onToggleLocked, | ||
| canAdmin = false, | ||
| }: BlockMenuProps) { | ||
| const isSingleBlock = selectedBlocks.length === 1 | ||
| const allEnabled = selectedBlocks.every((b) => b.enabled) | ||
| const allDisabled = selectedBlocks.every((b) => !b.enabled) | ||
| const allLocked = selectedBlocks.every((b) => b.locked) | ||
| const allUnlocked = selectedBlocks.every((b) => !b.locked) | ||
| // Can't unlock blocks that have locked parents | ||
| const hasBlockWithLockedParent = selectedBlocks.some((b) => b.locked && b.isParentLocked) | ||
| // Can't enable blocks that have disabled parents | ||
| const hasBlockWithDisabledParent = selectedBlocks.some((b) => !b.enabled && b.isParentDisabled) | ||
| const hasSingletonBlock = selectedBlocks.some( | ||
| (b) => | ||
| @@ -108,6 +127,12 @@ export function BlockMenu({ | ||
| return 'Toggle Enabled' | ||
| } | ||
| const getToggleLockedLabel = () => { | ||
| if (allLocked) return 'Unlock' | ||
| if (allUnlocked) return 'Lock' | ||
| return 'Toggle Lock' | ||
| } | ||
| return ( | ||
| <Popover | ||
| open={isOpen} | ||
| @@ -139,7 +164,7 @@ export function BlockMenu({ | ||
| </PopoverItem> | ||
| <PopoverItem | ||
| className='group' | ||
| disabled={disableEdit || !hasClipboard} | ||
| disabled={!userCanEdit || !hasClipboard} | ||
| onClick={() => { | ||
| onPaste() | ||
| onClose() | ||
| @@ -164,13 +189,15 @@ export function BlockMenu({ | ||
| {!allNoteBlocks && <PopoverDivider />} | ||
| {!allNoteBlocks && ( | ||
| <PopoverItem | ||
| disabled={disableEdit} | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| disabled={disableEdit || hasBlockWithDisabledParent} | ||
| onClick={() => { | ||
| onToggleEnabled() | ||
| onClose() | ||
| if (!disableEdit && !hasBlockWithDisabledParent) { | ||
| onToggleEnabled() | ||
| onClose() | ||
| } | ||
| }} | ||
| > | ||
| {getToggleEnabledLabel()} | ||
| {hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()} | ||
| </PopoverItem> | ||
| )} | ||
| {!allNoteBlocks && !isSubflow && ( | ||
| @@ -195,6 +222,19 @@ export function BlockMenu({ | ||
| Remove from Subflow | ||
| </PopoverItem> | ||
| )} | ||
| {canAdmin && onToggleLocked && ( | ||
| <PopoverItem | ||
| disabled={hasBlockWithLockedParent} | ||
| onClick={() => { | ||
| if (!hasBlockWithLockedParent) { | ||
| onToggleLocked() | ||
| onClose() | ||
| } | ||
| }} | ||
| > | ||
| {hasBlockWithLockedParent ? 'Parent is locked' : getToggleLockedLabel()} | ||
| </PopoverItem> | ||
| )} | ||
| {/* Single block actions */} | ||
| {isSingleBlock && <PopoverDivider />} | ||
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.