Skip to content

Commit b1aa826

Browse files
committed
feat(workflow): lock/unlock workflow from context menu and panel
1 parent 870d4b5 commit b1aa826

File tree

7 files changed

+179
-4
lines changed

7 files changed

+179
-4
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import type { RefObject } from 'react'
4+
import { Lock, Unlock } from 'lucide-react'
45
import {
56
Popover,
67
PopoverAnchor,
@@ -26,16 +27,22 @@ export interface CanvasMenuProps {
2627
onOpenLogs: () => void
2728
onToggleVariables: () => void
2829
onToggleChat: () => void
30+
onToggleWorkflowLock?: () => void
2931
isVariablesOpen?: boolean
3032
isChatOpen?: boolean
3133
hasClipboard?: boolean
3234
disableEdit?: boolean
3335
disableAdmin?: boolean
36+
canAdmin?: boolean
3437
canUndo?: boolean
3538
canRedo?: boolean
3639
isInvitationsDisabled?: boolean
3740
/** Whether the workflow has locked blocks (disables auto-layout) */
3841
hasLockedBlocks?: boolean
42+
/** Whether all blocks in the workflow are locked */
43+
allBlocksLocked?: boolean
44+
/** Whether the workflow has any blocks */
45+
hasBlocks?: boolean
3946
}
4047

4148
/**
@@ -56,13 +63,17 @@ export function CanvasMenu({
5663
onOpenLogs,
5764
onToggleVariables,
5865
onToggleChat,
66+
onToggleWorkflowLock,
5967
isVariablesOpen = false,
6068
isChatOpen = false,
6169
hasClipboard = false,
6270
disableEdit = false,
71+
canAdmin = false,
6372
canUndo = false,
6473
canRedo = false,
6574
hasLockedBlocks = false,
75+
allBlocksLocked = false,
76+
hasBlocks = false,
6677
}: CanvasMenuProps) {
6778
return (
6879
<Popover
@@ -142,6 +153,22 @@ export function CanvasMenu({
142153
<span>Auto-layout</span>
143154
<span className='ml-auto opacity-70 group-hover:opacity-100'>⇧L</span>
144155
</PopoverItem>
156+
{canAdmin && onToggleWorkflowLock && (
157+
<PopoverItem
158+
disabled={!hasBlocks}
159+
onClick={() => {
160+
onToggleWorkflowLock()
161+
onClose()
162+
}}
163+
>
164+
{allBlocksLocked ? (
165+
<Unlock className='h-3 w-3' />
166+
) : (
167+
<Lock className='h-3 w-3' />
168+
)}
169+
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
170+
</PopoverItem>
171+
)}
145172
<PopoverItem
146173
onClick={() => {
147174
onFitToView()

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export const Notifications = memo(function Notifications() {
6161
case 'refresh':
6262
window.location.reload()
6363
break
64+
case 'unlock-workflow':
65+
window.dispatchEvent(new CustomEvent('unlock-workflow'))
66+
break
6467
default:
6568
logger.warn('Unknown action type', { notificationId, actionType: action.type })
6669
}
@@ -175,7 +178,9 @@ export const Notifications = memo(function Notifications() {
175178
? 'Fix in Copilot'
176179
: notification.action!.type === 'refresh'
177180
? 'Refresh'
178-
: 'Take action'}
181+
: notification.action!.type === 'unlock-workflow'
182+
? 'Unlock Workflow'
183+
: 'Take action'}
179184
</Button>
180185
)}
181186
</div>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

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

131+
const allBlocksLocked = useWorkflowStore((state) => {
132+
const blockList = Object.values(state.blocks)
133+
return blockList.length > 0 && blockList.every((block) => block.locked)
134+
})
135+
136+
const hasBlocks = useWorkflowStore((state) => Object.keys(state.blocks).length > 0)
137+
138+
const { collaborativeBatchToggleLocked } = useCollaborativeWorkflow()
139+
129140
// Delete workflow hook
130141
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
131142
workspaceId,
@@ -329,6 +340,17 @@ export const Panel = memo(function Panel() {
329340
workspaceId,
330341
])
331342

343+
/**
344+
* Toggles the locked state of all blocks in the workflow
345+
*/
346+
const handleToggleWorkflowLock = useCallback(() => {
347+
const blocks = useWorkflowStore.getState().blocks
348+
const allLocked = Object.values(blocks).every((b) => b.locked)
349+
const ids = getWorkflowLockToggleIds(blocks, !allLocked)
350+
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
351+
setIsMenuOpen(false)
352+
}, [collaborativeBatchToggleLocked])
353+
332354
// Compute run button state
333355
const canRun = userPermissions.canRead // Running only requires read permissions
334356
const isLoadingPermissions = userPermissions.isLoading
@@ -399,6 +421,19 @@ export const Panel = memo(function Panel() {
399421
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
400422
<span>Auto layout</span>
401423
</PopoverItem>
424+
{userPermissions.canAdmin && (
425+
<PopoverItem
426+
onClick={handleToggleWorkflowLock}
427+
disabled={!hasBlocks}
428+
>
429+
{allBlocksLocked ? (
430+
<Unlock className='h-3 w-3' />
431+
) : (
432+
<Lock className='h-3 w-3' />
433+
)}
434+
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
435+
</PopoverItem>
436+
)}
402437
{
403438
<PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
404439
<VariableIcon className='h-3 w-3' />

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1298,7 +1298,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
12981298
</Tooltip.Content>
12991299
</Tooltip.Root>
13001300
)}
1301-
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
1301+
{!isEnabled && !isLocked && <Badge variant='gray-secondary'>disabled</Badge>}
13021302
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
13031303

13041304
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,35 @@ export function isEdgeProtected(
5858
* @param blocks - Record of all blocks in the workflow
5959
* @returns Result containing deletable IDs, protected IDs, and whether all are protected
6060
*/
61+
/**
62+
* Returns block IDs ordered so that `batchToggleLocked` will target the desired state.
63+
*
64+
* `batchToggleLocked` determines its target locked state from `!firstBlock.locked`.
65+
* When `targetLocked` is true (lock all), an unlocked block must come first.
66+
* When `targetLocked` is false (unlock all), a locked block must come first.
67+
*
68+
* @param blocks - Record of all blocks in the workflow
69+
* @param targetLocked - The desired locked state for all blocks
70+
* @returns Sorted block IDs, or empty array if there are no blocks
71+
*/
72+
export function getWorkflowLockToggleIds(
73+
blocks: Record<string, BlockState>,
74+
targetLocked: boolean
75+
): string[] {
76+
const ids = Object.keys(blocks)
77+
if (ids.length === 0) return []
78+
79+
ids.sort((a, b) => {
80+
const aVal = blocks[a].locked ? 1 : 0
81+
const bVal = blocks[b].locked ? 1 : 0
82+
// To lock all (targetLocked=true): unlocked first (aVal - bVal)
83+
// To unlock all (targetLocked=false): locked first (bVal - aVal)
84+
return targetLocked ? aVal - bVal : bVal - aVal
85+
})
86+
87+
return ids
88+
}
89+
6190
export function filterProtectedBlocks(
6291
blockIds: string[],
6392
blocks: Record<string, BlockState>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
estimateBlockDimensions,
5858
filterProtectedBlocks,
5959
getClampedPositionForNode,
60+
getWorkflowLockToggleIds,
6061
isBlockProtected,
6162
isEdgeProtected,
6263
isInEditableElement,
@@ -393,6 +394,13 @@ const WorkflowContent = React.memo(() => {
393394

394395
const { blocks, edges, lastSaved } = currentWorkflow
395396

397+
const allBlocksLocked = useMemo(() => {
398+
const blockList = Object.values(blocks)
399+
return blockList.length > 0 && blockList.every((b) => b.locked)
400+
}, [blocks])
401+
402+
const hasBlocks = useMemo(() => Object.keys(blocks).length > 0, [blocks])
403+
396404
const isWorkflowReady = useMemo(
397405
() =>
398406
hydration.phase === 'ready' &&
@@ -1175,6 +1183,73 @@ const WorkflowContent = React.memo(() => {
11751183
collaborativeBatchToggleLocked(blockIds)
11761184
}, [contextMenuBlocks, collaborativeBatchToggleLocked])
11771185

1186+
const handleToggleWorkflowLock = useCallback(() => {
1187+
const currentBlocks = useWorkflowStore.getState().blocks
1188+
const allLocked = Object.values(currentBlocks).every((b) => b.locked)
1189+
const ids = getWorkflowLockToggleIds(currentBlocks, !allLocked)
1190+
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
1191+
}, [collaborativeBatchToggleLocked])
1192+
1193+
// Show notification when all blocks in the workflow are locked
1194+
const lockNotificationIdRef = useRef<string | null>(null)
1195+
1196+
const clearLockNotification = useCallback(() => {
1197+
if (lockNotificationIdRef.current) {
1198+
useNotificationStore.getState().removeNotification(lockNotificationIdRef.current)
1199+
lockNotificationIdRef.current = null
1200+
}
1201+
}, [])
1202+
1203+
// Reset notification when switching workflows so it recreates for the new workflow
1204+
const prevWorkflowIdRef = useRef(activeWorkflowId)
1205+
const prevCanAdminRef = useRef(effectivePermissions.canAdmin)
1206+
useEffect(() => {
1207+
if (!isWorkflowReady) return
1208+
1209+
const workflowChanged = prevWorkflowIdRef.current !== activeWorkflowId
1210+
const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin
1211+
prevWorkflowIdRef.current = activeWorkflowId
1212+
prevCanAdminRef.current = effectivePermissions.canAdmin
1213+
1214+
// Clear stale notification when workflow or admin status changes
1215+
if ((workflowChanged || canAdminChanged) && lockNotificationIdRef.current) {
1216+
clearLockNotification()
1217+
}
1218+
1219+
if (allBlocksLocked) {
1220+
if (lockNotificationIdRef.current) return
1221+
1222+
const isAdmin = effectivePermissions.canAdmin
1223+
lockNotificationIdRef.current = addNotification({
1224+
level: 'info',
1225+
message: isAdmin
1226+
? 'This workflow is locked'
1227+
: 'This workflow is locked. Ask an admin to unlock it.',
1228+
workflowId: activeWorkflowId || undefined,
1229+
...(isAdmin
1230+
? { action: { type: 'unlock-workflow' as const, message: '' } }
1231+
: {}),
1232+
})
1233+
} else {
1234+
clearLockNotification()
1235+
}
1236+
}, [allBlocksLocked, isWorkflowReady, effectivePermissions.canAdmin, addNotification, activeWorkflowId, clearLockNotification])
1237+
1238+
// Clean up notification on unmount
1239+
useEffect(() => clearLockNotification, [clearLockNotification])
1240+
1241+
// Listen for unlock-workflow events from notification action button
1242+
useEffect(() => {
1243+
const handleUnlockWorkflow = () => {
1244+
const currentBlocks = useWorkflowStore.getState().blocks
1245+
const ids = getWorkflowLockToggleIds(currentBlocks, false)
1246+
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
1247+
}
1248+
1249+
window.addEventListener('unlock-workflow', handleUnlockWorkflow)
1250+
return () => window.removeEventListener('unlock-workflow', handleUnlockWorkflow)
1251+
}, [collaborativeBatchToggleLocked])
1252+
11781253
const handleContextRemoveFromSubflow = useCallback(() => {
11791254
const blocksToRemove = contextMenuBlocks.filter(
11801255
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -3700,6 +3775,10 @@ const WorkflowContent = React.memo(() => {
37003775
canUndo={canUndo}
37013776
canRedo={canRedo}
37023777
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
3778+
onToggleWorkflowLock={handleToggleWorkflowLock}
3779+
allBlocksLocked={allBlocksLocked}
3780+
canAdmin={effectivePermissions.canAdmin}
3781+
hasBlocks={hasBlocks}
37033782
/>
37043783
</>
37053784
)}

apps/sim/stores/notifications/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export interface NotificationAction {
66
/**
77
* Action type identifier for handler reconstruction
88
*/
9-
type: 'copilot' | 'refresh'
9+
type: 'copilot' | 'refresh' | 'unlock-workflow'
1010

1111
/**
1212
* Message or data to pass to the action handler.

0 commit comments

Comments
 (0)