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}