Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -450,6 +455,13 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
<p>{isExecuting ? 'Stop' : 'Run workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
<Deploy
activeWorkflowId={workflowId}
userPermissions={effectivePermissions}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
compact
disabled={isWorkflowMapLoading || isFolderMapLoading || isWorkflowLocked}
/>
</>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) => (
<button type='button' {...props}>
{children}
</button>
),
cn: (...classes: Array<string | undefined>) => classes.filter(Boolean).join(' '),
Tooltip: {
Root: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Trigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
},
}))

vi.mock('@sim/emcn/icons', () => ({
Upload: () => <span />,
}))

vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal',
() => ({
DeployModal: ({ open }: { open: boolean }) =>
open ? <div role='dialog'>Deploy workflow</div> : 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<typeof mockState> = {},
props: { disabled?: boolean; canAdmin?: boolean } = {}
) {
Object.assign(mockState, overrides)
act(() => {
root.render(
<Deploy
activeWorkflowId='workflow-1'
userPermissions={{
canRead: true,
canEdit: true,
canAdmin: props.canAdmin ?? true,
userPermissions: props.canAdmin === false ? 'write' : 'admin',
isLoading: false,
error: null,
}}
compact
disabled={props.disabled}
/>
)
})
}

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()
})
})
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,18 +18,24 @@ interface DeployProps {
activeWorkflowId: string | null
userPermissions: WorkspaceUserPermissions
className?: string
compact?: boolean
disabled?: boolean
}

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
Comment thread
cursor[bot] marked this conversation as resolved.
const { hasBlocks } = useCurrentWorkflow()

const { data: deploymentInfo } = useDeploymentInfo(activeWorkflowId, {
Expand Down Expand Up @@ -82,6 +89,9 @@ export function Deploy({
}

const getTooltipText = () => {
if (isRegistryLoading) {
return 'Loading workflow...'
}
if (isEmpty) {
return 'Cannot deploy an empty workflow'
}
Expand Down Expand Up @@ -119,24 +129,34 @@ export function Deploy({
return 'Deploy'
}

const buttonLabel = getButtonLabel()
const tooltipText = getTooltipText()

return (
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span>
<Button
className='h-[30px] gap-1.5 px-2.5'
className={cn(compact ? 'h-[30px]' : 'h-[30px] gap-1.5 px-2.5', className)}
variant={
isRegistryLoading ? 'active' : changeDetected || !isDeployed ? 'tertiary' : 'active'
compact
? 'subtle'
: isRegistryLoading
? 'active'
: changeDetected || !isDeployed
? 'tertiary'
: 'active'
}
onClick={onDeployClick}
disabled={isRegistryLoading || isDisabled}
aria-label={compact ? buttonLabel : undefined}
>
{getButtonLabel()}
{compact ? <Upload className='size-[16px] text-[var(--text-icon)]' /> : buttonLabel}
</Button>
</span>
</Tooltip.Trigger>
<Tooltip.Content>{getTooltipText()}</Tooltip.Content>
<Tooltip.Content>{tooltipText}</Tooltip.Content>
</Tooltip.Root>

<DeployModal
Expand Down
Loading