Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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