diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index b741b7619683ac..896bd279c5296f 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -150,6 +150,7 @@ vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), }), + useSearchParams: () => new URLSearchParams(), })) vi.mock('@/context/event-emitter', () => ({ @@ -417,6 +418,10 @@ vi.mock('../hooks/use-workflow-search', () => ({ useWorkflowSearch: workflowHookMocks.useWorkflowSearch, })) +vi.mock('../hooks/use-locate-node', () => ({ + useLocateNode: vi.fn(), +})) + vi.mock('../nodes/_base/components/variable/use-match-schema-type', () => ({ default: () => ({ schemaTypeDefinitions: undefined, diff --git a/web/app/components/workflow/hooks/__tests__/use-locate-node.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-locate-node.spec.tsx new file mode 100644 index 00000000000000..b78cec39b10d19 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-locate-node.spec.tsx @@ -0,0 +1,178 @@ +import type { CommonNodeType, Node } from '../../types' +import { renderHook } from '@testing-library/react' +import { useLocateNode } from '../use-locate-node' + +const mockHandleNodeSelect = vi.hoisted(() => vi.fn()) +const mockScrollToWorkflowNode = vi.hoisted(() => vi.fn()) +const mockSearchParams = vi.hoisted(() => new URLSearchParams()) +const mockToastSuccess = vi.hoisted(() => vi.fn()) +const mockToastError = vi.hoisted(() => vi.fn()) + +vi.mock('@/next/navigation', () => ({ + useSearchParams: () => mockSearchParams, +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: Object.assign(vi.fn(), { + success: mockToastSuccess, + error: mockToastError, + warning: vi.fn(), + info: vi.fn(), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + }), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options?.nodeId) + return `${key}:${options.nodeId}` + if (options?.title) + return `${key}:${options.title}` + return key + }, + }), +})) + +vi.mock('../use-nodes-interactions', () => ({ + useNodesInteractions: () => ({ + handleNodeSelect: mockHandleNodeSelect, + }), +})) + +vi.mock('../../utils/node-navigation', () => ({ + scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId), +})) + +const createNode = (overrides: Partial = {}): Node => ({ + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + type: 'llm', + title: 'Writer', + desc: 'Draft content', + } as CommonNodeType, + ...overrides, +}) + +describe('useLocateNode', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mockSearchParams.delete('node_id') + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does nothing when node_id param is absent', () => { + const nodes = [createNode({ id: 'n1' })] + renderHook(() => useLocateNode(nodes)) + + expect(mockHandleNodeSelect).not.toHaveBeenCalled() + expect(mockToastSuccess).not.toHaveBeenCalled() + expect(mockToastError).not.toHaveBeenCalled() + }) + + it('does nothing when nodes are empty', () => { + mockSearchParams.set('node_id', 'n1') + renderHook(() => useLocateNode([])) + + expect(mockHandleNodeSelect).not.toHaveBeenCalled() + expect(mockToastError).not.toHaveBeenCalled() + }) + + it('selects, scrolls to, and shows success toast when node is found', () => { + mockSearchParams.set('node_id', 'target-node') + const nodes = [createNode({ id: 'target-node', data: { title: 'My Node' } as CommonNodeType })] + + renderHook(() => useLocateNode(nodes)) + + expect(mockHandleNodeSelect).toHaveBeenCalledWith('target-node') + expect(mockToastSuccess).toHaveBeenCalledWith( + 'panel.locateNodeSuccess:My Node', + ) + + // Scroll happens after a 200ms delay + expect(mockScrollToWorkflowNode).not.toHaveBeenCalled() + vi.advanceTimersByTime(200) + expect(mockScrollToWorkflowNode).toHaveBeenCalledWith('target-node') + }) + + it('shows error toast after debounce when node is not found', () => { + mockSearchParams.set('node_id', 'missing-node') + const nodes = [createNode({ id: 'other-node' })] + + renderHook(() => useLocateNode(nodes)) + + // Not immediately reported + expect(mockToastError).not.toHaveBeenCalled() + + // After 500ms debounce, reports not found + vi.advanceTimersByTime(500) + expect(mockToastError).toHaveBeenCalledWith( + 'panel.locateNodeNotFound:missing-node', + ) + }) + + it('does not report not-found prematurely when nodes are still loading', () => { + mockSearchParams.set('node_id', 'target-node') + const initialNodes = [createNode({ id: 'other-node' })] + + const { rerender } = renderHook( + ({ nodes }) => useLocateNode(nodes), + { initialProps: { nodes: initialNodes } }, + ) + + // Before debounce fires, more nodes arrive including the target + vi.advanceTimersByTime(300) + rerender({ + nodes: [ + createNode({ id: 'other-node' }), + createNode({ id: 'target-node', data: { title: 'Found!' } as CommonNodeType }), + ], + }) + + // Should have located the node, not reported error + expect(mockToastError).not.toHaveBeenCalled() + expect(mockHandleNodeSelect).toHaveBeenCalledWith('target-node') + expect(mockToastSuccess).toHaveBeenCalledWith('panel.locateNodeSuccess:Found!') + }) + + it('locates only once even if nodes update afterwards', () => { + mockSearchParams.set('node_id', 'target-node') + const nodes1 = [createNode({ id: 'target-node' })] + + const { rerender } = renderHook( + ({ nodes }) => useLocateNode(nodes), + { initialProps: { nodes: nodes1 } }, + ) + + expect(mockHandleNodeSelect).toHaveBeenCalledTimes(1) + + // Simulate a nodes update after locate has already happened + rerender({ + nodes: [createNode({ id: 'target-node' }), createNode({ id: 'extra' })], + }) + + expect(mockHandleNodeSelect).toHaveBeenCalledTimes(1) + expect(mockToastSuccess).toHaveBeenCalledTimes(1) + }) + + it('clears scroll timeout on unmount', () => { + mockSearchParams.set('node_id', 'target-node') + const nodes = [createNode({ id: 'target-node' })] + + const { unmount } = renderHook(() => useLocateNode(nodes)) + + // Unmount before the 200ms scroll timer fires + unmount() + + vi.advanceTimersByTime(500) + expect(mockScrollToWorkflowNode).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx index 4e9f4c9b45ddd2..271373bd9ee419 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx @@ -93,13 +93,68 @@ describe('useWorkflowSearch', () => { const toolResults = await workflowNodesAction.search('', 'search') expect(toolResults.map(item => item.id)).toEqual(['tool-1']) - expect(toolResults[0]?.description).toBe('Search the web') + // description now includes nodeId suffix: "desc · nodeId" + expect(toolResults[0]?.description).toBe('Search the web · tool-1') unmount() expect(workflowNodesAction.searchFn).toBeUndefined() }) + it('matches by node_id with highest priority scoring', async () => { + runtimeNodes.push( + createNode({ + id: '1721234567890', + data: { + type: BlockEnum.LLM, + title: 'Writer', + desc: 'Draft content', + } as CommonNodeType, + }), + createNode({ + id: 'other-node', + data: { + type: BlockEnum.LLM, + title: '1721234567890', // title also matches the searchTerm + desc: '', + } as CommonNodeType, + }), + ) + + const { unmount } = renderHook(() => useWorkflowSearch()) + + // Exact nodeId match (120pts) should rank higher than title exact match (100pts) + const results = await workflowNodesAction.search('', '1721234567890') + expect(results.map(item => item.id)).toEqual(['1721234567890', 'other-node']) + + unmount() + }) + + it('matches by partial node_id', async () => { + runtimeNodes.push( + createNode({ + id: '1721234567890', + data: { + type: BlockEnum.LLM, + title: 'Writer', + desc: 'Draft content', + } as CommonNodeType, + }), + ) + + const { unmount } = renderHook(() => useWorkflowSearch()) + + // Prefix match + const prefixResults = await workflowNodesAction.search('', '172123') + expect(prefixResults.map(item => item.id)).toEqual(['1721234567890']) + + // Partial match + const partialResults = await workflowNodesAction.search('', '456') + expect(partialResults.map(item => item.id)).toEqual(['1721234567890']) + + unmount() + }) + it('binds the node selection listener to handleNodeSelect', () => { const { unmount } = renderHook(() => useWorkflowSearch()) diff --git a/web/app/components/workflow/hooks/use-locate-node.ts b/web/app/components/workflow/hooks/use-locate-node.ts new file mode 100644 index 00000000000000..147b4ca8f013d6 --- /dev/null +++ b/web/app/components/workflow/hooks/use-locate-node.ts @@ -0,0 +1,81 @@ +'use client' + +import type { Node } from '../types' +import { toast } from '@langgenius/dify-ui/toast' +import { useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { useSearchParams } from '@/next/navigation' +import { scrollToWorkflowNode } from '../utils/node-navigation' +import { useNodesInteractions } from './use-nodes-interactions' + +/** + * Hook to locate a node by ID from URL query parameter `node_id`. + * + * Usage scenario: operators find a failing node ID from server logs, + * construct a URL like `/workflow?node_id=xxx`, and the editor will + * automatically select and scroll to that node on load. + * + * The hook reads `node_id` from the URL search params, waits for nodes + * to be available, then selects and scrolls to the target node. + * A toast message is shown to indicate success or failure. + */ +export const useLocateNode = (nodes: Node[]) => { + const { t } = useTranslation() + const searchParams = useSearchParams() + const nodeIdFromUrl = searchParams.get('node_id') + const { handleNodeSelect } = useNodesInteractions() + const hasLocateRef = useRef(false) + + useEffect(() => { + if (!nodeIdFromUrl || hasLocateRef.current) + return + + // Wait for nodes to be loaded + if (!nodes.length) + return + + const targetNode = nodes.find(n => n.id === nodeIdFromUrl) + + if (!targetNode) { + // Don't mark as located yet — nodes may still be loading asynchronously. + // Retry on the next nodes update before reporting "not found". + return + } + + // Select the node (opens its panel) and scroll to it + handleNodeSelect(nodeIdFromUrl) + + // Delay scroll to ensure node selection state has been applied + const scrollTimer = setTimeout(() => { + scrollToWorkflowNode(nodeIdFromUrl) + }, 200) + + toast.success(t('panel.locateNodeSuccess', { ns: 'workflow', title: targetNode.data?.title || nodeIdFromUrl })) + hasLocateRef.current = true + + return () => clearTimeout(scrollTimer) + }, [nodeIdFromUrl, nodes, handleNodeSelect, t]) + + // Report "not found" after nodes have settled (no longer changing) + useEffect(() => { + if (!nodeIdFromUrl || hasLocateRef.current || !nodes.length) + return + + const targetNode = nodes.find(n => n.id === nodeIdFromUrl) + if (targetNode) + return + + // Debounce: wait to see if nodes continue loading before reporting not found + const notFoundTimer = setTimeout(() => { + if (hasLocateRef.current) + return + const stillMissing = !nodes.find(n => n.id === nodeIdFromUrl) + if (stillMissing) { + toast.error(t('panel.locateNodeNotFound', { ns: 'workflow', nodeId: nodeIdFromUrl })) + hasLocateRef.current = true + } + }, 500) + + return () => clearTimeout(notFoundTimer) + }, [nodeIdFromUrl, nodes, t]) +} diff --git a/web/app/components/workflow/hooks/use-workflow-search.tsx b/web/app/components/workflow/hooks/use-workflow-search.tsx index 8ca597f94eb117..4576be72d87859 100644 --- a/web/app/components/workflow/hooks/use-workflow-search.tsx +++ b/web/app/components/workflow/hooks/use-workflow-search.tsx @@ -79,6 +79,7 @@ export const useWorkflowSearch = () => { return { id: node.id, + nodeId: node.id, title: nodeData?.title || nodeData?.type || 'Untitled', type: nodeData?.type || '', desc: nodeData?.desc || '', @@ -92,6 +93,7 @@ export const useWorkflowSearch = () => { // Calculate search score - clean scoring logic const calculateScore = useCallback((node: { + nodeId: string title: string type: string desc: string @@ -103,12 +105,21 @@ export const useWorkflowSearch = () => { const titleMatch = node.title.toLowerCase() const typeMatch = node.type.toLowerCase() const descMatch = node.desc?.toLowerCase() || '' + const nodeIdMatch = node.nodeId?.toLowerCase() || '' const modelProviderMatch = node.modelInfo?.provider?.toLowerCase() || '' const modelNameMatch = node.modelInfo?.name?.toLowerCase() || '' const modelModeMatch = node.modelInfo?.mode?.toLowerCase() || '' let score = 0 + // Node ID matching (exact > partial — useful for locating nodes from server logs) + if (nodeIdMatch === searchTerm) + score += 120 + else if (nodeIdMatch.startsWith(searchTerm)) + score += 90 + else if (nodeIdMatch.includes(searchTerm)) + score += 40 + // Title matching (exact prefix > partial match) if (titleMatch.startsWith(searchTerm)) score += 100 @@ -151,7 +162,8 @@ export const useWorkflowSearch = () => { ? { id: node.id, title: node.title, - description: node.desc || node.type, + description: [node.desc || node.type, node.nodeId].filter(Boolean).join(' · '), + nodeId: node.nodeId, type: 'workflow-node' as const, path: `#${node.id}`, icon: ( diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index a0eed15fe6e18f..5491ec3b091273 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -93,6 +93,7 @@ import { useWorkflowRefreshDraft, } from './hooks' import { HooksStoreContextProvider, useHooksStore } from './hooks-store' +import { useLocateNode } from './hooks/use-locate-node' import { useWorkflowComment } from './hooks/use-workflow-comment' import { useWorkflowSearch } from './hooks/use-workflow-search' import CustomNode from './nodes' @@ -564,6 +565,9 @@ export const Workflow: FC = memo(({ // Initialize workflow node search functionality useWorkflowSearch() + // Locate a node by ID from URL query parameter `node_id` + useLocateNode(nodes) + // Set up scroll to node event listener using the utility function useEffect(() => { return setupScrollToNodeListener(nodes, reactflow) diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index e511d29b8544ff..fb27e6223b08db 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1193,6 +1193,8 @@ "panel.goTo": "Go to", "panel.goToFix": "Go to fix", "panel.helpLink": "View Docs", + "panel.locateNodeNotFound": "Node not found: {{nodeId}}", + "panel.locateNodeSuccess": "Located node: {{title}}", "panel.nextStep": "Next Step", "panel.openWorkflow": "Open Workflow", "panel.optional": "(optional)", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 0135c42722dadf..cb9847d7488444 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1193,6 +1193,8 @@ "panel.goTo": "转到", "panel.goToFix": "前往修复", "panel.helpLink": "查看帮助文档", + "panel.locateNodeNotFound": "未找到节点:{{nodeId}}", + "panel.locateNodeSuccess": "已定位到节点:{{title}}", "panel.nextStep": "下一步", "panel.openWorkflow": "打开工作流", "panel.optional": "(选填)",