diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
index f1bfdc3086c..b91a47eeeea 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
@@ -52,12 +52,14 @@ import {
useWorkspacePermissionsContext,
} from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/table'
+import { Deploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy'
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
-import { useFolders } from '@/hooks/queries/folders'
+import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import { useLogDetail } from '@/hooks/queries/logs'
import { downloadTableExport } from '@/hooks/queries/tables'
-import { useWorkflows } from '@/hooks/queries/workflows'
+import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree'
+import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useExecutionStore } from '@/stores/execution/store'
@@ -360,6 +362,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
const { data: session } = useSession()
const hostContext = useWorkspaceHostContext()
const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext()
+ const { data: workflowMap = {}, isLoading: isWorkflowMapLoading } = useWorkflowMap(workspaceId)
+ const { data: folderMap = {}, isLoading: isFolderMapLoading } = useFolderMap(workspaceId)
const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow)
const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
const isExecuting = useExecutionStore(
@@ -379,6 +383,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
const isRunButtonDisabled =
!isExecuting &&
(isUsageGateLoading || (!effectivePermissions.canRead && !effectivePermissions.isLoading))
+ const isWorkflowLocked = isWorkflowEffectivelyLocked(workflowMap[workflowId], folderMap)
const handleRun = async () => {
setActiveWorkflow(workflowId)
@@ -450,6 +455,13 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
{isExecuting ? 'Stop' : 'Run workflow'}
+
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx
new file mode 100644
index 00000000000..09b21a5c0db
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx
@@ -0,0 +1,188 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mockState = vi.hoisted(() => ({
+ hydrationPhase: 'ready' as 'idle' | 'state-loading' | 'ready',
+ hydrationWorkflowId: 'workflow-1' as string | null,
+ registryActiveWorkflowId: 'workflow-1' as string | null,
+ hasBlocks: true,
+ isDeployed: false,
+ changeDetected: false,
+ isChangeDetectionSettling: false,
+ isDeploying: false,
+ readiness: {
+ isBlocked: false,
+ isSyncing: false,
+ tooltip: 'Ready to deploy',
+ },
+ handleDeployClick: vi.fn(),
+}))
+
+vi.mock('@sim/emcn', () => ({
+ Button: ({ children, ...props }: React.ButtonHTMLAttributes) => (
+
+ ),
+ cn: (...classes: Array) => classes.filter(Boolean).join(' '),
+ Tooltip: {
+ Root: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ Trigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ Content: ({ children }: { children: React.ReactNode }) => {children}
,
+ },
+}))
+
+vi.mock('@sim/emcn/icons', () => ({
+ Upload: () => ,
+}))
+
+vi.mock(
+ '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal',
+ () => ({
+ DeployModal: ({ open }: { open: boolean }) =>
+ open ? Deploy workflow
: null,
+ })
+)
+
+vi.mock(
+ '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks',
+ () => ({
+ useChangeDetection: () => ({
+ changeDetected: mockState.changeDetected,
+ isChangeDetectionSettling: mockState.isChangeDetectionSettling,
+ }),
+ useDeployment: () => ({
+ isDeploying: mockState.isDeploying,
+ handleDeployClick: mockState.handleDeployClick,
+ }),
+ useDeployReadiness: () => ({
+ ...mockState.readiness,
+ status: mockState.readiness.isBlocked ? 'saving' : 'ready',
+ isReady: !mockState.readiness.isBlocked,
+ waitUntilReady: vi.fn(),
+ }),
+ })
+)
+
+vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow', () => ({
+ useCurrentWorkflow: () => ({ hasBlocks: () => mockState.hasBlocks }),
+}))
+
+vi.mock('@/hooks/queries/deployments', () => ({
+ useDeploymentInfo: () => ({ data: { isDeployed: mockState.isDeployed } }),
+ useDeployedWorkflowState: () => ({ data: null, isLoading: false, isFetching: false }),
+}))
+
+vi.mock('@/stores/workflows/registry/store', () => ({
+ useWorkflowRegistry: (selector: (state: unknown) => unknown) =>
+ selector({
+ activeWorkflowId: mockState.registryActiveWorkflowId,
+ hydration: {
+ phase: mockState.hydrationPhase,
+ workflowId: mockState.hydrationWorkflowId,
+ },
+ }),
+}))
+
+import { Deploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy'
+
+let container: HTMLDivElement
+let root: Root
+
+function renderDeploy(
+ overrides: Partial = {},
+ props: { disabled?: boolean; canAdmin?: boolean } = {}
+) {
+ Object.assign(mockState, overrides)
+ act(() => {
+ root.render(
+
+ )
+ })
+}
+
+beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ mockState.hydrationPhase = 'ready'
+ mockState.hydrationWorkflowId = 'workflow-1'
+ mockState.registryActiveWorkflowId = 'workflow-1'
+ mockState.hasBlocks = true
+ mockState.isDeployed = false
+ mockState.changeDetected = false
+ mockState.isChangeDetectionSettling = false
+ mockState.isDeploying = false
+ mockState.readiness = {
+ isBlocked: false,
+ isSyncing: false,
+ tooltip: 'Ready to deploy',
+ }
+ mockState.handleDeployClick.mockReset()
+ mockState.handleDeployClick.mockResolvedValue({ success: true, shouldOpenModal: true })
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+describe('Deploy compact mode', () => {
+ it.each([
+ ['Deploy', {}],
+ ['Live', { isDeployed: true }],
+ ['Update', { isDeployed: true, changeDetected: true }],
+ ])('exposes the %s action for its deployment state', (label, overrides) => {
+ renderDeploy(overrides)
+
+ expect(container.querySelector('button')?.getAttribute('aria-label')).toBe(label)
+ })
+
+ it.each([
+ ['non-admin users', {}, { canAdmin: false }],
+ ['empty workflows', { hasBlocks: false }, {}],
+ ['locked workflows', {}, { disabled: true }],
+ [
+ 'unsynchronized workflows',
+ {
+ readiness: { isBlocked: true, isSyncing: false, tooltip: 'Saving workflow changes' },
+ },
+ {},
+ ],
+ [
+ 'workflows that are still loading',
+ { hydrationWorkflowId: 'workflow-2', registryActiveWorkflowId: 'workflow-2' },
+ {},
+ ],
+ ])('disables the action for %s', (_reason, overrides, props) => {
+ renderDeploy(overrides, props)
+
+ expect(container.querySelector('button')?.disabled).toBe(true)
+ })
+
+ it('opens the existing deployment modal after a successful deployment action', async () => {
+ renderDeploy()
+
+ await act(async () => {
+ container.querySelector('button')?.click()
+ })
+
+ expect(container.querySelector('[role="dialog"]')).not.toBeNull()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx
index 598207320da..859d8cd33f8 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx
@@ -1,7 +1,8 @@
'use client'
import { useState } from 'react'
-import { Button, Tooltip } from '@sim/emcn'
+import { Button, cn, Tooltip } from '@sim/emcn'
+import { Upload } from '@sim/emcn/icons'
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal'
import {
useChangeDetection,
@@ -17,6 +18,7 @@ interface DeployProps {
activeWorkflowId: string | null
userPermissions: WorkspaceUserPermissions
className?: string
+ compact?: boolean
disabled?: boolean
}
@@ -24,11 +26,16 @@ export function Deploy({
activeWorkflowId,
userPermissions,
className,
+ compact = false,
disabled = false,
}: DeployProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
- const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
- const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading'
+ const registryActiveWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
+ const hydration = useWorkflowRegistry((state) => state.hydration)
+ const isRegistryLoading =
+ hydration.phase !== 'ready' ||
+ registryActiveWorkflowId !== activeWorkflowId ||
+ hydration.workflowId !== activeWorkflowId
const { hasBlocks } = useCurrentWorkflow()
const { data: deploymentInfo } = useDeploymentInfo(activeWorkflowId, {
@@ -82,6 +89,9 @@ export function Deploy({
}
const getTooltipText = () => {
+ if (isRegistryLoading) {
+ return 'Loading workflow...'
+ }
if (isEmpty) {
return 'Cannot deploy an empty workflow'
}
@@ -119,24 +129,34 @@ export function Deploy({
return 'Deploy'
}
+ const buttonLabel = getButtonLabel()
+ const tooltipText = getTooltipText()
+
return (
<>
- {getTooltipText()}
+ {tooltipText}