Fix workflow card toggle logic and implement minimal state UI (#24822)

This commit is contained in:
lyzno1 2025-08-30 16:35:34 +08:00 committed by GitHub
parent 60b5ed8e5d
commit a35be05790
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 308 additions and 22 deletions

View File

@ -0,0 +1,228 @@
import { getWorkflowEntryNode } from '@/app/components/workflow/utils/workflow-entry'
// Mock the getWorkflowEntryNode function
jest.mock('@/app/components/workflow/utils/workflow-entry', () => ({
getWorkflowEntryNode: jest.fn(),
}))
const mockGetWorkflowEntryNode = getWorkflowEntryNode as jest.MockedFunction<typeof getWorkflowEntryNode>
describe('App Card Toggle Logic', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Helper function that mirrors the actual logic from app-card.tsx
const calculateToggleState = (
appMode: string,
currentWorkflow: any,
isCurrentWorkspaceEditor: boolean,
isCurrentWorkspaceManager: boolean,
cardType: 'webapp' | 'api',
) => {
const isWorkflowApp = appMode === 'workflow'
const appUnpublished = isWorkflowApp && !currentWorkflow?.graph
const hasEntryNode = mockGetWorkflowEntryNode(currentWorkflow?.graph?.nodes || [])
const missingEntryNode = isWorkflowApp && !hasEntryNode
const hasInsufficientPermissions = cardType === 'webapp' ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingEntryNode
const isMinimalState = appUnpublished || missingEntryNode
return {
toggleDisabled,
isMinimalState,
appUnpublished,
missingEntryNode,
hasInsufficientPermissions,
}
}
describe('Entry Node Detection Logic', () => {
it('should disable toggle when workflow missing entry node', () => {
mockGetWorkflowEntryNode.mockReturnValue(false)
const result = calculateToggleState(
'workflow',
{ graph: { nodes: [] } },
true,
true,
'webapp',
)
expect(result.toggleDisabled).toBe(true)
expect(result.missingEntryNode).toBe(true)
expect(result.isMinimalState).toBe(true)
})
it('should enable toggle when workflow has entry node', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
const result = calculateToggleState(
'workflow',
{ graph: { nodes: [{ data: { type: 'start' } }] } },
true,
true,
'webapp',
)
expect(result.toggleDisabled).toBe(false)
expect(result.missingEntryNode).toBe(false)
expect(result.isMinimalState).toBe(false)
})
})
describe('Published State Logic', () => {
it('should disable toggle when workflow unpublished (no graph)', () => {
const result = calculateToggleState(
'workflow',
null, // No workflow data = unpublished
true,
true,
'webapp',
)
expect(result.toggleDisabled).toBe(true)
expect(result.appUnpublished).toBe(true)
expect(result.isMinimalState).toBe(true)
})
it('should disable toggle when workflow unpublished (empty graph)', () => {
const result = calculateToggleState(
'workflow',
{}, // No graph property = unpublished
true,
true,
'webapp',
)
expect(result.toggleDisabled).toBe(true)
expect(result.appUnpublished).toBe(true)
expect(result.isMinimalState).toBe(true)
})
it('should consider published state when workflow has graph', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
const result = calculateToggleState(
'workflow',
{ graph: { nodes: [] } },
true,
true,
'webapp',
)
expect(result.appUnpublished).toBe(false)
})
})
describe('Permissions Logic', () => {
it('should disable webapp toggle when user lacks editor permissions', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
const result = calculateToggleState(
'workflow',
{ graph: { nodes: [] } },
false, // No editor permission
true,
'webapp',
)
expect(result.toggleDisabled).toBe(true)
expect(result.hasInsufficientPermissions).toBe(true)
})
it('should disable api toggle when user lacks manager permissions', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
const result = calculateToggleState(
'workflow',
{ graph: { nodes: [] } },
true,
false, // No manager permission
'api',
)
expect(result.toggleDisabled).toBe(true)
expect(result.hasInsufficientPermissions).toBe(true)
})
it('should enable toggle when user has proper permissions', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
const webappResult = calculateToggleState(
'workflow',
{ graph: { nodes: [] } },
true, // Has editor permission
false,
'webapp',
)
const apiResult = calculateToggleState(
'workflow',
{ graph: { nodes: [] } },
false,
true, // Has manager permission
'api',
)
expect(webappResult.toggleDisabled).toBe(false)
expect(apiResult.toggleDisabled).toBe(false)
})
})
describe('Combined Conditions Logic', () => {
it('should handle multiple disable conditions correctly', () => {
mockGetWorkflowEntryNode.mockReturnValue(false)
const result = calculateToggleState(
'workflow',
null, // Unpublished
false, // No permissions
false,
'webapp',
)
// All three conditions should be true
expect(result.appUnpublished).toBe(true)
expect(result.missingEntryNode).toBe(true)
expect(result.hasInsufficientPermissions).toBe(true)
expect(result.toggleDisabled).toBe(true)
expect(result.isMinimalState).toBe(true)
})
it('should enable when all conditions are satisfied', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
const result = calculateToggleState(
'workflow',
{ graph: { nodes: [{ data: { type: 'start' } }] } }, // Published
true, // Has permissions
true,
'webapp',
)
expect(result.appUnpublished).toBe(false)
expect(result.missingEntryNode).toBe(false)
expect(result.hasInsufficientPermissions).toBe(false)
expect(result.toggleDisabled).toBe(false)
expect(result.isMinimalState).toBe(false)
})
})
describe('Non-Workflow Apps', () => {
it('should not check workflow-specific conditions for non-workflow apps', () => {
const result = calculateToggleState(
'chat', // Non-workflow mode
null,
true,
true,
'webapp',
)
expect(result.appUnpublished).toBe(false) // isWorkflowApp is false
expect(result.missingEntryNode).toBe(false) // isWorkflowApp is false
expect(result.toggleDisabled).toBe(false)
expect(result.isMinimalState).toBe(false)
})
})
})

View File

@ -41,7 +41,8 @@ import AccessControl from '../app-access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import { useAppWorkflow } from '@/service/use-workflow'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { BlockEnum } from '@/app/components/workflow/types'
import { getWorkflowEntryNode } from '@/app/components/workflow/utils/workflow-entry'
import { useDocLink } from '@/context/i18n'
export type IAppCardProps = {
className?: string
@ -68,6 +69,7 @@ function AppCard({
const pathname = usePathname()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === 'workflow' ? appInfo.id : '')
const docLink = useDocLink()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const [showSettingsModal, setShowSettingsModal] = useState(false)
@ -103,10 +105,14 @@ function AppCard({
const basicName = isApp
? t('appOverview.overview.appInfo.title')
: t('appOverview.overview.apiInfo.title')
const hasStartNode = currentWorkflow?.graph?.nodes.find(node => node.data.type === BlockEnum.Start)
const isWorkflowAndMissingStart = appInfo.mode === 'workflow' && !hasStartNode
const toggleDisabled = isWorkflowAndMissingStart || (isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager)
const runningStatus = isWorkflowAndMissingStart ? false : (isApp ? appInfo.enable_site : appInfo.enable_api)
const isWorkflowApp = appInfo.mode === 'workflow'
const appUnpublished = isWorkflowApp && !currentWorkflow?.graph
const hasEntryNode = getWorkflowEntryNode(currentWorkflow?.graph?.nodes || [])
const missingEntryNode = isWorkflowApp && !hasEntryNode
const hasInsufficientPermissions = isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingEntryNode
const runningStatus = (appUnpublished || missingEntryNode) ? false : (isApp ? appInfo.enable_site : appInfo.enable_api)
const isMinimalState = appUnpublished || missingEntryNode
const { app_base_url, access_token } = appInfo.site ?? {}
const appMode = (appInfo.mode !== 'completion' && appInfo.mode !== 'workflow') ? 'chat' : appInfo.mode
const appUrl = `${app_base_url}${basePath}/${appMode}/${access_token}`
@ -180,10 +186,10 @@ function AppCard({
return (
<div
className={
`${isInPanel ? 'border-l-[0.5px] border-t' : 'border-[0.5px] shadow-xs'} w-full max-w-full rounded-xl border-effects-highlight ${className ?? ''}`}
`${isInPanel ? 'border-l-[0.5px] border-t' : 'border-[0.5px] shadow-xs'} w-full max-w-full rounded-xl border-effects-highlight ${className ?? ''} ${isMinimalState ? 'h-12' : ''}`}
>
<div className={`${customBgColor ?? 'bg-background-default'} rounded-xl`}>
<div className='flex w-full flex-col items-start justify-center gap-3 self-stretch border-b-[0.5px] border-divider-subtle p-3'>
<div className={`flex w-full flex-col items-start justify-center gap-3 self-stretch p-3 ${isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle'}`}>
<div className='flex w-full items-center gap-3 self-stretch'>
<AppBasic
iconType={cardType}
@ -205,9 +211,37 @@ function AppCard({
: t('appOverview.overview.status.disable')}
</div>
</div>
<Switch defaultValue={runningStatus} onChange={onChangeStatus} disabled={toggleDisabled} />
{isApp ? (
<Tooltip
popupContent={
toggleDisabled && (appUnpublished || missingEntryNode) ? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('appOverview.overview.appInfo.enableTooltip.description')}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/guides/workflow/node/start'), '_blank')}
>
{t('appOverview.overview.appInfo.enableTooltip.learnMore')}
</div>
</>
) : ''
}
position="right"
popupClassName="w-58 max-w-60 rounded-xl border-[0.5px] p-3.5 shadow-lg backdrop-blur-[10px]"
offset={24}
>
<div>
<Switch defaultValue={runningStatus} onChange={onChangeStatus} disabled={toggleDisabled} />
</div>
</Tooltip>
) : (
<Switch defaultValue={runningStatus} onChange={onChangeStatus} disabled={toggleDisabled} />
)}
</div>
<div className='flex flex-col items-start justify-center self-stretch'>
{!isMinimalState && (
<div className='flex flex-col items-start justify-center self-stretch'>
<div className="system-xs-medium pb-1 text-text-tertiary">
{isApp
? t('appOverview.overview.appInfo.accessibleAddress')
@ -256,7 +290,8 @@ function AppCard({
)}
</div>
</div>
{isApp && systemFeatures.webapp_auth.enabled && appDetail && <div className='flex flex-col items-start justify-center self-stretch'>
)}
{!isMinimalState && isApp && systemFeatures.webapp_auth.enabled && appDetail && <div className='flex flex-col items-start justify-center self-stretch'>
<div className="system-xs-medium pb-1 text-text-tertiary">{t('app.publishApp.title')}</div>
<div className='flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2'
onClick={handleClickAccessControl}>
@ -292,9 +327,10 @@ function AppCard({
</div>
</div>}
</div>
<div className={'flex items-center gap-1 self-stretch p-3'}>
{!isApp && <SecretKeyButton appId={appInfo.id} />}
{OPERATIONS_MAP[cardType].map((op) => {
{!isMinimalState && (
<div className={'flex items-center gap-1 self-stretch p-3'}>
{!isApp && <SecretKeyButton appId={appInfo.id} />}
{OPERATIONS_MAP[cardType].map((op) => {
const disabled
= op.opName === t('appOverview.overview.appInfo.settings.entry')
? false
@ -322,7 +358,8 @@ function AppCard({
</Button>
)
})}
</div>
</div>
)}
</div>
{isApp
? (

View File

@ -24,6 +24,7 @@ import {
useUpdateMCPServer,
} from '@/service/use-tools'
import { BlockEnum } from '@/app/components/workflow/types'
import { getWorkflowEntryNode } from '@/app/components/workflow/utils/workflow-entry'
import cn from '@/utils/classnames'
import { fetchAppDetail } from '@/service/apps'
@ -69,12 +70,16 @@ function MCPServiceCard({
const { data: detail } = useMCPServerDetail(appId)
const { id, status, server_code } = detail ?? {}
const isWorkflowApp = appInfo.mode === 'workflow'
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
const serverPublished = !!id
const serverActivated = status === 'active'
const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********'
const hasStartNode = currentWorkflow?.graph?.nodes.find(node => node.data.type === BlockEnum.Start)
const toggleDisabled = !isCurrentWorkspaceEditor || appUnpublished || !hasStartNode
const hasEntryNode = getWorkflowEntryNode(currentWorkflow?.graph?.nodes || [])
const missingEntryNode = isWorkflowApp && !hasEntryNode
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingEntryNode
const isMinimalState = appUnpublished || missingEntryNode
const [activated, setActivated] = useState(serverActivated)
@ -137,9 +142,9 @@ function MCPServiceCard({
return (
<>
<div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight')}>
<div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}>
<div className='rounded-xl bg-background-default'>
<div className='flex w-full flex-col items-start justify-center gap-3 self-stretch border-b-[0.5px] border-divider-subtle p-3'>
<div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}>
<div className='flex w-full items-center gap-3 self-stretch'>
<div className='flex grow items-center'>
<div className='mr-2 shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'>
@ -167,7 +172,8 @@ function MCPServiceCard({
</div>
</Tooltip>
</div>
<div className='flex flex-col items-start justify-center self-stretch'>
{!isMinimalState && (
<div className='flex flex-col items-start justify-center self-stretch'>
<div className="system-xs-medium pb-1 text-text-tertiary">
{t('tools.mcp.server.url')}
</div>
@ -199,9 +205,11 @@ function MCPServiceCard({
</>
)}
</div>
</div>
</div>
)}
</div>
<div className='flex items-center gap-1 self-stretch p-3'>
{!isMinimalState && (
<div className='flex items-center gap-1 self-stretch p-3'>
<Button
disabled={toggleDisabled}
size='small'
@ -214,7 +222,8 @@ function MCPServiceCard({
<div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('tools.mcp.server.edit') : t('tools.mcp.server.addDescription')}</div>
</div>
</Button>
</div>
</div>
)}
</div>
</div>
{showMCPServerModal && (

View File

@ -38,6 +38,10 @@ const translation = {
regenerate: 'Regenerate',
regenerateNotice: 'Do you want to regenerate the public URL?',
preUseReminder: 'Please enable web app before continuing.',
enableTooltip: {
description: 'To enable this feature, please add a User Input node to the canvas.',
learnMore: 'Learn more',
},
settings: {
entry: 'Settings',
title: 'Web App Settings',

View File

@ -37,6 +37,10 @@ const translation = {
regenerate: '再生成',
regenerateNotice: '公開 URL を再生成しますか?',
preUseReminder: '続行する前に Web アプリを有効にしてください。',
enableTooltip: {
description: 'この機能を有効にするには、キャンバスにユーザー入力ノードを追加してください。',
learnMore: '詳細を見る',
},
settings: {
entry: '設定',
title: 'Web アプリの設定',

View File

@ -38,6 +38,10 @@ const translation = {
regenerate: '重新生成',
regenerateNotice: '您是否要重新生成公开访问 URL',
preUseReminder: '使用前请先打开开关',
enableTooltip: {
description: '要启用此功能,请在画布中添加用户输入节点。',
learnMore: '了解更多',
},
settings: {
entry: '设置',
title: 'web app 设置',