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 @@ -150,6 +150,7 @@ vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
}))

vi.mock('@/context/event-emitter', () => ({
Expand Down Expand Up @@ -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,
Expand Down
178 changes: 178 additions & 0 deletions web/app/components/workflow/hooks/__tests__/use-locate-node.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
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> = {}): 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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
81 changes: 81 additions & 0 deletions web/app/components/workflow/hooks/use-locate-node.ts
Original file line number Diff line number Diff line change
@@ -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])
}
14 changes: 13 additions & 1 deletion web/app/components/workflow/hooks/use-workflow-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '',
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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: (
Expand Down
Loading
Loading