Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,21 @@ export interface CanvasMenuProps {
onOpenLogs: () => void
onToggleVariables: () => void
onToggleChat: () => void
onToggleWorkflowLock?: () => void
isVariablesOpen?: boolean
isChatOpen?: boolean
hasClipboard?: boolean
disableEdit?: boolean
disableAdmin?: boolean
canAdmin?: boolean
canUndo?: boolean
canRedo?: boolean
isInvitationsDisabled?: boolean
/** Whether the workflow has locked blocks (disables auto-layout) */
hasLockedBlocks?: boolean
/** Whether all blocks in the workflow are locked */
allBlocksLocked?: boolean
/** Whether the workflow has any blocks */
hasBlocks?: boolean
}

/**
Expand All @@ -56,13 +61,17 @@ export function CanvasMenu({
onOpenLogs,
onToggleVariables,
onToggleChat,
onToggleWorkflowLock,
isVariablesOpen = false,
isChatOpen = false,
hasClipboard = false,
disableEdit = false,
canAdmin = false,
canUndo = false,
canRedo = false,
hasLockedBlocks = false,
allBlocksLocked = false,
hasBlocks = false,
}: CanvasMenuProps) {
return (
<Popover
Expand Down Expand Up @@ -142,6 +151,17 @@ export function CanvasMenu({
<span>Auto-layout</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>⇧L</span>
</PopoverItem>
{canAdmin && onToggleWorkflowLock && (
<PopoverItem
disabled={!hasBlocks}
onClick={() => {
onToggleWorkflowLock()
onClose()
}}
>
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
</PopoverItem>
)}
<PopoverItem
onClick={() => {
onFitToView()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export const Notifications = memo(function Notifications() {
case 'refresh':
window.location.reload()
break
case 'unlock-workflow':
window.dispatchEvent(new CustomEvent('unlock-workflow'))
break
default:
logger.warn('Unknown action type', { notificationId, actionType: action.type })
}
Expand Down Expand Up @@ -175,7 +178,9 @@ export const Notifications = memo(function Notifications() {
? 'Fix in Copilot'
: notification.action!.type === 'refresh'
? 'Refresh'
: 'Take action'}
: notification.action!.type === 'unlock-workflow'
? 'Unlock Workflow'
: 'Take action'}
</Button>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ArrowUp, Square } from 'lucide-react'
import { ArrowUp, Lock, Square, Unlock } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import {
Expand Down Expand Up @@ -42,7 +42,9 @@ import {
import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables'
import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useChatStore } from '@/stores/chat/store'
import { useNotificationStore } from '@/stores/notifications/store'
Expand Down Expand Up @@ -126,6 +128,15 @@ export const Panel = memo(function Panel() {
Object.values(state.blocks).some((block) => block.locked)
)

const allBlocksLocked = useWorkflowStore((state) => {
const blockList = Object.values(state.blocks)
return blockList.length > 0 && blockList.every((block) => block.locked)
})

const hasBlocks = useWorkflowStore((state) => Object.keys(state.blocks).length > 0)

const { collaborativeBatchToggleLocked } = useCollaborativeWorkflow()

// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
Expand Down Expand Up @@ -329,6 +340,17 @@ export const Panel = memo(function Panel() {
workspaceId,
])

/**
* Toggles the locked state of all blocks in the workflow
*/
const handleToggleWorkflowLock = useCallback(() => {
const blocks = useWorkflowStore.getState().blocks
const allLocked = Object.values(blocks).every((b) => b.locked)
const ids = getWorkflowLockToggleIds(blocks, !allLocked)
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
setIsMenuOpen(false)
}, [collaborativeBatchToggleLocked])

// Compute run button state
const canRun = userPermissions.canRead // Running only requires read permissions
const isLoadingPermissions = userPermissions.isLoading
Expand Down Expand Up @@ -399,6 +421,16 @@ export const Panel = memo(function Panel() {
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
<span>Auto layout</span>
</PopoverItem>
{userPermissions.canAdmin && !currentWorkflow?.isSnapshotView && (
<PopoverItem onClick={handleToggleWorkflowLock} disabled={!hasBlocks}>
{allBlocksLocked ? (
<Unlock className='h-3 w-3' />
) : (
<Lock className='h-3 w-3' />
)}
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
</PopoverItem>
)}
{
<PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
<VariableIcon className='h-3 w-3' />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1298,7 +1298,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</Tooltip.Content>
</Tooltip.Root>
)}
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{!isEnabled && !isLocked && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}

{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,38 @@ export function filterProtectedBlocks(
allProtected: protectedIds.length === blockIds.length && blockIds.length > 0,
}
}

/**
* Returns block IDs ordered so that `batchToggleLocked` will target the desired state.
*
* `batchToggleLocked` determines its target locked state from `!firstBlock.locked`.
* When `targetLocked` is true (lock all), an unlocked block must come first.
* When `targetLocked` is false (unlock all), a locked block must come first.
*
* Returns an empty array when there are no blocks or all blocks already match `targetLocked`.
*
* @param blocks - Record of all blocks in the workflow
* @param targetLocked - The desired locked state for all blocks
* @returns Sorted block IDs, or empty array if no toggle is needed
*/
export function getWorkflowLockToggleIds(
blocks: Record<string, BlockState>,
targetLocked: boolean
): string[] {
const ids = Object.keys(blocks)
if (ids.length === 0) return []

// No-op if all blocks already match the desired state
const allMatch = Object.values(blocks).every((b) => Boolean(b.locked) === targetLocked)
if (allMatch) return []

ids.sort((a, b) => {
const aVal = blocks[a].locked ? 1 : 0
const bVal = blocks[b].locked ? 1 : 0
// To lock all (targetLocked=true): unlocked first (aVal - bVal)
// To unlock all (targetLocked=false): locked first (bVal - aVal)
return targetLocked ? aVal - bVal : bVal - aVal
})

return ids
}
101 changes: 100 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
estimateBlockDimensions,
filterProtectedBlocks,
getClampedPositionForNode,
getWorkflowLockToggleIds,
isBlockProtected,
isEdgeProtected,
isInEditableElement,
Expand Down Expand Up @@ -393,6 +394,15 @@ const WorkflowContent = React.memo(() => {

const { blocks, edges, lastSaved } = currentWorkflow

const allBlocksLocked = useMemo(() => {
const blockList = Object.values(blocks)
return blockList.length > 0 && blockList.every((b) => b.locked)
}, [blocks])

const hasBlocks = useMemo(() => Object.keys(blocks).length > 0, [blocks])

const hasLockedBlocks = useMemo(() => Object.values(blocks).some((b) => b.locked), [blocks])

const isWorkflowReady = useMemo(
() =>
hydration.phase === 'ready' &&
Expand Down Expand Up @@ -1175,6 +1185,91 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleLocked(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleLocked])

const handleToggleWorkflowLock = useCallback(() => {
const currentBlocks = useWorkflowStore.getState().blocks
const allLocked = Object.values(currentBlocks).every((b) => b.locked)
const ids = getWorkflowLockToggleIds(currentBlocks, !allLocked)
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
}, [collaborativeBatchToggleLocked])

// Show notification when all blocks in the workflow are locked
const lockNotificationIdRef = useRef<string | null>(null)

const clearLockNotification = useCallback(() => {
if (lockNotificationIdRef.current) {
useNotificationStore.getState().removeNotification(lockNotificationIdRef.current)
lockNotificationIdRef.current = null
}
}, [])

// Clear persisted lock notifications on mount/workflow change (prevents duplicates after reload)
useEffect(() => {
// Reset ref so the main effect creates a fresh notification for the new workflow
clearLockNotification()

if (!activeWorkflowId) return
const store = useNotificationStore.getState()
const stale = store.notifications.filter(
(n) =>
n.workflowId === activeWorkflowId &&
(n.action?.type === 'unlock-workflow' || n.message.startsWith('This workflow is locked'))
)
for (const n of stale) {
store.removeNotification(n.id)
}
}, [activeWorkflowId, clearLockNotification])

const prevCanAdminRef = useRef(effectivePermissions.canAdmin)
useEffect(() => {
if (!isWorkflowReady) return

const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin
prevCanAdminRef.current = effectivePermissions.canAdmin

// Clear stale notification when admin status changes so it recreates with correct message
if (canAdminChanged) {
clearLockNotification()
}

if (allBlocksLocked) {
if (lockNotificationIdRef.current) return

const isAdmin = effectivePermissions.canAdmin
lockNotificationIdRef.current = addNotification({
level: 'info',
message: isAdmin
? 'This workflow is locked'
: 'This workflow is locked. Ask an admin to unlock it.',
workflowId: activeWorkflowId || undefined,
...(isAdmin ? { action: { type: 'unlock-workflow' as const, message: '' } } : {}),
})
} else {
clearLockNotification()
}
}, [
allBlocksLocked,
isWorkflowReady,
effectivePermissions.canAdmin,
addNotification,
activeWorkflowId,
clearLockNotification,
])

// Clean up notification on unmount
useEffect(() => clearLockNotification, [clearLockNotification])

// Listen for unlock-workflow events from notification action button
useEffect(() => {
const handleUnlockWorkflow = () => {
const currentBlocks = useWorkflowStore.getState().blocks
const ids = getWorkflowLockToggleIds(currentBlocks, false)
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
}

window.addEventListener('unlock-workflow', handleUnlockWorkflow)
return () => window.removeEventListener('unlock-workflow', handleUnlockWorkflow)
}, [collaborativeBatchToggleLocked])

const handleContextRemoveFromSubflow = useCallback(() => {
const blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
Expand Down Expand Up @@ -3699,7 +3794,11 @@ const WorkflowContent = React.memo(() => {
disableEdit={!effectivePermissions.canEdit}
canUndo={canUndo}
canRedo={canRedo}
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
hasLockedBlocks={hasLockedBlocks}
onToggleWorkflowLock={handleToggleWorkflowLock}
allBlocksLocked={allBlocksLocked}
canAdmin={effectivePermissions.canAdmin}
hasBlocks={hasBlocks}
/>
</>
)}
Expand Down
Loading
Loading