mirror of https://github.com/langgenius/dify.git
Fix workflow card toggle logic and implement minimal state UI (#24822)
This commit is contained in:
parent
60b5ed8e5d
commit
a35be05790
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
? (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ const translation = {
|
|||
regenerate: '再生成',
|
||||
regenerateNotice: '公開 URL を再生成しますか?',
|
||||
preUseReminder: '続行する前に Web アプリを有効にしてください。',
|
||||
enableTooltip: {
|
||||
description: 'この機能を有効にするには、キャンバスにユーザー入力ノードを追加してください。',
|
||||
learnMore: '詳細を見る',
|
||||
},
|
||||
settings: {
|
||||
entry: '設定',
|
||||
title: 'Web アプリの設定',
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ const translation = {
|
|||
regenerate: '重新生成',
|
||||
regenerateNotice: '您是否要重新生成公开访问 URL?',
|
||||
preUseReminder: '使用前请先打开开关',
|
||||
enableTooltip: {
|
||||
description: '要启用此功能,请在画布中添加用户输入节点。',
|
||||
learnMore: '了解更多',
|
||||
},
|
||||
settings: {
|
||||
entry: '设置',
|
||||
title: 'web app 设置',
|
||||
|
|
|
|||
Loading…
Reference in New Issue