Skip to content

Commit 46ff6ef

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

File tree

6 files changed

+179
-4
lines changed

6 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: 46 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 {
@@ -43,6 +43,7 @@ import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen
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'
4545
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
46+
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
4647
import { usePermissionConfig } from '@/hooks/use-permission-config'
4748
import { useChatStore } from '@/stores/chat/store'
4849
import { useNotificationStore } from '@/stores/notifications/store'
@@ -126,6 +127,15 @@ export const Panel = memo(function Panel() {
126127
Object.values(state.blocks).some((block) => block.locked)
127128
)
128129

130+
const allBlocksLocked = useWorkflowStore((state) => {
131+
const blockList = Object.values(state.blocks)
132+
return blockList.length > 0 && blockList.every((block) => block.locked)
133+
})
134+
135+
const hasBlocks = useWorkflowStore((state) => Object.keys(state.blocks).length > 0)
136+
137+
const { collaborativeBatchToggleLocked } = useCollaborativeWorkflow()
138+
129139
// Delete workflow hook
130140
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
131141
workspaceId,
@@ -329,6 +339,28 @@ export const Panel = memo(function Panel() {
329339
workspaceId,
330340
])
331341

342+
/**
343+
* Toggles the locked state of all blocks in the workflow
344+
*/
345+
const handleToggleWorkflowLock = useCallback(() => {
346+
const blocks = useWorkflowStore.getState().blocks
347+
const blockList = Object.values(blocks)
348+
if (blockList.length === 0) return
349+
350+
const allLocked = blockList.every((b) => b.locked)
351+
const ids = Object.keys(blocks)
352+
353+
// Sort so the first block guarantees the correct target state for batchToggleLocked
354+
ids.sort((a, b) => {
355+
const aVal = blocks[a].locked ? 1 : 0
356+
const bVal = blocks[b].locked ? 1 : 0
357+
return allLocked ? bVal - aVal : aVal - bVal
358+
})
359+
360+
collaborativeBatchToggleLocked(ids)
361+
setIsMenuOpen(false)
362+
}, [collaborativeBatchToggleLocked])
363+
332364
// Compute run button state
333365
const canRun = userPermissions.canRead // Running only requires read permissions
334366
const isLoadingPermissions = userPermissions.isLoading
@@ -399,6 +431,19 @@ export const Panel = memo(function Panel() {
399431
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
400432
<span>Auto layout</span>
401433
</PopoverItem>
434+
{userPermissions.canAdmin && (
435+
<PopoverItem
436+
onClick={handleToggleWorkflowLock}
437+
disabled={!hasBlocks}
438+
>
439+
{allBlocksLocked ? (
440+
<Unlock className='h-3 w-3' />
441+
) : (
442+
<Lock className='h-3 w-3' />
443+
)}
444+
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
445+
</PopoverItem>
446+
)}
402447
{
403448
<PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
404449
<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]/workflow.tsx

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,13 @@ const WorkflowContent = React.memo(() => {
393393

394394
const { blocks, edges, lastSaved } = currentWorkflow
395395

396+
const allBlocksLocked = useMemo(() => {
397+
const blockList = Object.values(blocks)
398+
return blockList.length > 0 && blockList.every((b) => b.locked)
399+
}, [blocks])
400+
401+
const hasBlocks = useMemo(() => Object.keys(blocks).length > 0, [blocks])
402+
396403
const isWorkflowReady = useMemo(
397404
() =>
398405
hydration.phase === 'ready' &&
@@ -1175,6 +1182,93 @@ const WorkflowContent = React.memo(() => {
11751182
collaborativeBatchToggleLocked(blockIds)
11761183
}, [contextMenuBlocks, collaborativeBatchToggleLocked])
11771184

1185+
const handleToggleWorkflowLock = useCallback(() => {
1186+
const currentBlocks = useWorkflowStore.getState().blocks
1187+
const blockList = Object.values(currentBlocks)
1188+
if (blockList.length === 0) return
1189+
1190+
const allLocked = blockList.every((b) => b.locked)
1191+
const ids = Object.keys(currentBlocks)
1192+
1193+
// Sort so the first block guarantees the correct target state for batchToggleLocked
1194+
ids.sort((a, b) => {
1195+
const aVal = currentBlocks[a].locked ? 1 : 0
1196+
const bVal = currentBlocks[b].locked ? 1 : 0
1197+
return allLocked ? bVal - aVal : aVal - bVal
1198+
})
1199+
1200+
collaborativeBatchToggleLocked(ids)
1201+
}, [collaborativeBatchToggleLocked])
1202+
1203+
// Show notification when all blocks in the workflow are locked
1204+
const lockNotificationIdRef = useRef<string | null>(null)
1205+
const prevCanAdminRef = useRef(effectivePermissions.canAdmin)
1206+
useEffect(() => {
1207+
if (!isWorkflowReady) return
1208+
1209+
// Clear stale notification when admin status changes so it recreates with correct message
1210+
const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin
1211+
prevCanAdminRef.current = effectivePermissions.canAdmin
1212+
if (canAdminChanged && lockNotificationIdRef.current) {
1213+
useNotificationStore.getState().removeNotification(lockNotificationIdRef.current)
1214+
lockNotificationIdRef.current = null
1215+
}
1216+
1217+
if (allBlocksLocked) {
1218+
if (lockNotificationIdRef.current) return
1219+
1220+
const isAdmin = effectivePermissions.canAdmin
1221+
lockNotificationIdRef.current = addNotification({
1222+
level: 'info',
1223+
message: isAdmin
1224+
? 'This workflow is locked'
1225+
: 'This workflow is locked. Ask an admin to unlock it.',
1226+
workflowId: activeWorkflowId || undefined,
1227+
...(isAdmin
1228+
? { action: { type: 'unlock-workflow' as const, message: '' } }
1229+
: {}),
1230+
})
1231+
} else if (lockNotificationIdRef.current) {
1232+
useNotificationStore.getState().removeNotification(lockNotificationIdRef.current)
1233+
lockNotificationIdRef.current = null
1234+
}
1235+
}, [allBlocksLocked, isWorkflowReady, effectivePermissions.canAdmin, addNotification, activeWorkflowId])
1236+
1237+
// Clean up notification on unmount
1238+
useEffect(() => {
1239+
return () => {
1240+
if (lockNotificationIdRef.current) {
1241+
useNotificationStore.getState().removeNotification(lockNotificationIdRef.current)
1242+
lockNotificationIdRef.current = null
1243+
}
1244+
}
1245+
}, [])
1246+
1247+
// Listen for unlock-workflow events from notification action button
1248+
useEffect(() => {
1249+
const handleUnlockWorkflow = () => {
1250+
const currentBlocks = useWorkflowStore.getState().blocks
1251+
const blockList = Object.values(currentBlocks)
1252+
if (blockList.length === 0) return
1253+
1254+
const anyLocked = blockList.some((b) => b.locked)
1255+
if (!anyLocked) return
1256+
1257+
const ids = Object.keys(currentBlocks)
1258+
// Ensure a locked block is first so batchToggleLocked targets unlock (false)
1259+
ids.sort((a, b) => {
1260+
const aVal = currentBlocks[a].locked ? 1 : 0
1261+
const bVal = currentBlocks[b].locked ? 1 : 0
1262+
return bVal - aVal
1263+
})
1264+
1265+
collaborativeBatchToggleLocked(ids)
1266+
}
1267+
1268+
window.addEventListener('unlock-workflow', handleUnlockWorkflow)
1269+
return () => window.removeEventListener('unlock-workflow', handleUnlockWorkflow)
1270+
}, [collaborativeBatchToggleLocked])
1271+
11781272
const handleContextRemoveFromSubflow = useCallback(() => {
11791273
const blocksToRemove = contextMenuBlocks.filter(
11801274
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -3700,6 +3794,10 @@ const WorkflowContent = React.memo(() => {
37003794
canUndo={canUndo}
37013795
canRedo={canRedo}
37023796
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
3797+
onToggleWorkflowLock={handleToggleWorkflowLock}
3798+
allBlocksLocked={allBlocksLocked}
3799+
canAdmin={effectivePermissions.canAdmin}
3800+
hasBlocks={hasBlocks}
37033801
/>
37043802
</>
37053803
)}

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)