From a35be057900b3742a49f1003315f24798db3173e Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Sat, 30 Aug 2025 16:35:34 +0800 Subject: [PATCH] Fix workflow card toggle logic and implement minimal state UI (#24822) --- .../overview/__tests__/toggle-logic.test.ts | 228 ++++++++++++++++++ web/app/components/app/overview/app-card.tsx | 65 +++-- .../components/tools/mcp/mcp-service-card.tsx | 25 +- web/i18n/en-US/app-overview.ts | 4 + web/i18n/ja-JP/app-overview.ts | 4 + web/i18n/zh-Hans/app-overview.ts | 4 + 6 files changed, 308 insertions(+), 22 deletions(-) create mode 100644 web/app/components/app/overview/__tests__/toggle-logic.test.ts diff --git a/web/app/components/app/overview/__tests__/toggle-logic.test.ts b/web/app/components/app/overview/__tests__/toggle-logic.test.ts new file mode 100644 index 0000000000..0c1e1ea0d3 --- /dev/null +++ b/web/app/components/app/overview/__tests__/toggle-logic.test.ts @@ -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 + +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) + }) + }) +}) diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index 14c372670f..8c598c07cf 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -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 (
-
+
- + {isApp ? ( + +
+ {t('appOverview.overview.appInfo.enableTooltip.description')} +
+
window.open(docLink('/guides/workflow/node/start'), '_blank')} + > + {t('appOverview.overview.appInfo.enableTooltip.learnMore')} +
+ + ) : '' + } + position="right" + popupClassName="w-58 max-w-60 rounded-xl border-[0.5px] p-3.5 shadow-lg backdrop-blur-[10px]" + offset={24} + > +
+ +
+
+ ) : ( + + )}
-
+ {!isMinimalState && ( +
{isApp ? t('appOverview.overview.appInfo.accessibleAddress') @@ -256,7 +290,8 @@ function AppCard({ )}
- {isApp && systemFeatures.webapp_auth.enabled && appDetail &&
+ )} + {!isMinimalState && isApp && systemFeatures.webapp_auth.enabled && appDetail &&
{t('app.publishApp.title')}
@@ -292,9 +327,10 @@ function AppCard({
}
-
- {!isApp && } - {OPERATIONS_MAP[cardType].map((op) => { + {!isMinimalState && ( +
+ {!isApp && } + {OPERATIONS_MAP[cardType].map((op) => { const disabled = op.opName === t('appOverview.overview.appInfo.settings.entry') ? false @@ -322,7 +358,8 @@ function AppCard({ ) })} -
+
+ )}
{isApp ? ( diff --git a/web/app/components/tools/mcp/mcp-service-card.tsx b/web/app/components/tools/mcp/mcp-service-card.tsx index 7d8d7aa1b3..076e944f0a 100644 --- a/web/app/components/tools/mcp/mcp-service-card.tsx +++ b/web/app/components/tools/mcp/mcp-service-card.tsx @@ -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 ( <> -
+
-
+
@@ -167,7 +172,8 @@ function MCPServiceCard({
-
+ {!isMinimalState && ( +
{t('tools.mcp.server.url')}
@@ -199,9 +205,11 @@ function MCPServiceCard({ )}
-
+
+ )}
-
+ {!isMinimalState && ( +
-
+
+ )}
{showMCPServerModal && ( diff --git a/web/i18n/en-US/app-overview.ts b/web/i18n/en-US/app-overview.ts index e2041f533c..3130f4d65d 100644 --- a/web/i18n/en-US/app-overview.ts +++ b/web/i18n/en-US/app-overview.ts @@ -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', diff --git a/web/i18n/ja-JP/app-overview.ts b/web/i18n/ja-JP/app-overview.ts index 320dfc9625..733b4f707c 100644 --- a/web/i18n/ja-JP/app-overview.ts +++ b/web/i18n/ja-JP/app-overview.ts @@ -37,6 +37,10 @@ const translation = { regenerate: '再生成', regenerateNotice: '公開 URL を再生成しますか?', preUseReminder: '続行する前に Web アプリを有効にしてください。', + enableTooltip: { + description: 'この機能を有効にするには、キャンバスにユーザー入力ノードを追加してください。', + learnMore: '詳細を見る', + }, settings: { entry: '設定', title: 'Web アプリの設定', diff --git a/web/i18n/zh-Hans/app-overview.ts b/web/i18n/zh-Hans/app-overview.ts index dcc3b994f7..bd30a2eed7 100644 --- a/web/i18n/zh-Hans/app-overview.ts +++ b/web/i18n/zh-Hans/app-overview.ts @@ -38,6 +38,10 @@ const translation = { regenerate: '重新生成', regenerateNotice: '您是否要重新生成公开访问 URL?', preUseReminder: '使用前请先打开开关', + enableTooltip: { + description: '要启用此功能,请在画布中添加用户输入节点。', + learnMore: '了解更多', + }, settings: { entry: '设置', title: 'web app 设置',