mirror of
https://github.com/langgenius/dify.git
synced 2026-07-25 21:48:30 +08:00
feat(web): locate workflow nodes by node_id in the workflow editor (#38187)
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
This commit is contained in:
parent
ba5b26be03
commit
14c50a9f09
@ -165,6 +165,7 @@ vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
@ -445,6 +446,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,
|
||||
|
||||
@ -0,0 +1,176 @@
|
||||
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: (
|
||||
selector: (source: Record<string, string>) => string,
|
||||
options?: Record<string, unknown>,
|
||||
) => {
|
||||
const key = selector(
|
||||
new Proxy({}, { get: (_, property: string) => property }) as Record<string, string>,
|
||||
)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -94,13 +94,68 @@ describe('useWorkflowSearch', () => {
|
||||
|
||||
const toolResults = findWorkflowNodes('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(findWorkflowNodes('gpt')).toEqual([])
|
||||
})
|
||||
|
||||
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 = findWorkflowNodes('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 = findWorkflowNodes('172123')
|
||||
expect(prefixResults.map((item) => item.id)).toEqual(['1721234567890'])
|
||||
|
||||
// Partial match
|
||||
const partialResults = findWorkflowNodes('456')
|
||||
expect(partialResults.map((item) => item.id)).toEqual(['1721234567890'])
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('binds the node selection listener to handleNodeSelect', () => {
|
||||
const { unmount } = renderHook(() => useWorkflowSearch())
|
||||
|
||||
|
||||
80
web/app/components/workflow/hooks/use-locate-node.ts
Normal file
80
web/app/components/workflow/hooks/use-locate-node.ts
Normal file
@ -0,0 +1,80 @@
|
||||
'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('workflow')
|
||||
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'], {
|
||||
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'], { nodeId: nodeIdFromUrl }))
|
||||
hasLocateRef.current = true
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(notFoundTimer)
|
||||
}, [nodeIdFromUrl, nodes, t])
|
||||
}
|
||||
@ -80,6 +80,7 @@ export const useWorkflowSearch = () => {
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
nodeId: node.id,
|
||||
title: nodeData?.title || nodeData?.type || 'Untitled',
|
||||
type: nodeData?.type || '',
|
||||
desc: nodeData?.desc || '',
|
||||
@ -95,6 +96,7 @@ export const useWorkflowSearch = () => {
|
||||
const calculateScore = useCallback(
|
||||
(
|
||||
node: {
|
||||
nodeId: string
|
||||
title: string
|
||||
type: string
|
||||
desc: string
|
||||
@ -107,12 +109,18 @@ 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
|
||||
else if (titleMatch.includes(searchTerm)) score += 50
|
||||
@ -149,7 +157,7 @@ export const useWorkflowSearch = () => {
|
||||
? {
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.desc || node.type,
|
||||
description: [node.desc || node.type, node.nodeId].filter(Boolean).join(' · '),
|
||||
type: 'workflow-node' as const,
|
||||
path: `#${node.id}`,
|
||||
icon: (
|
||||
|
||||
@ -70,6 +70,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 { shouldPreventWorkflowBrowserDefault } from './hotkeys'
|
||||
@ -565,6 +566,9 @@ export const Workflow: FC<WorkflowProps> = 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)
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "الذهاب إلى",
|
||||
"panel.goToFix": "الذهاب إلى الإصلاح",
|
||||
"panel.helpLink": "عرض المستندات",
|
||||
"panel.locateNodeNotFound": "العقدة غير موجودة: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "تم تحديد موقع العقدة: {{title}}",
|
||||
"panel.nextStep": "الخطوة التالية",
|
||||
"panel.openWorkflow": "فتح سير العمل",
|
||||
"panel.optional": "(اختياري)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Gehe zu",
|
||||
"panel.goToFix": "Zur Behebung",
|
||||
"panel.helpLink": "Hilfe",
|
||||
"panel.locateNodeNotFound": "Knoten nicht gefunden: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Knoten gefunden: {{title}}",
|
||||
"panel.nextStep": "Nächster Schritt",
|
||||
"panel.openWorkflow": "Workflow öffnen",
|
||||
"panel.optional": "(optional)",
|
||||
|
||||
@ -1199,6 +1199,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)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Ir a",
|
||||
"panel.goToFix": "Ir a corregir",
|
||||
"panel.helpLink": "Ayuda",
|
||||
"panel.locateNodeNotFound": "Nodo no encontrado: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Nodo localizado: {{title}}",
|
||||
"panel.nextStep": "Siguiente paso",
|
||||
"panel.openWorkflow": "Abrir flujo de trabajo",
|
||||
"panel.optional": "(opcional)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "برو به",
|
||||
"panel.goToFix": "برو به رفع",
|
||||
"panel.helpLink": "راهنما",
|
||||
"panel.locateNodeNotFound": "گره پیدا نشد: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "گره پیدا شد: {{title}}",
|
||||
"panel.nextStep": "مرحله بعدی",
|
||||
"panel.openWorkflow": "باز کردن گردش کار",
|
||||
"panel.optional": "(اختیاری)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Aller à",
|
||||
"panel.goToFix": "Aller corriger",
|
||||
"panel.helpLink": "Aide",
|
||||
"panel.locateNodeNotFound": "Nœud introuvable : {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Nœud localisé : {{title}}",
|
||||
"panel.nextStep": "Étape suivante",
|
||||
"panel.openWorkflow": "Ouvrir le flux de travail",
|
||||
"panel.optional": "(facultatif)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "जाओ",
|
||||
"panel.goToFix": "ठीक करने जाएँ",
|
||||
"panel.helpLink": "सहायता",
|
||||
"panel.locateNodeNotFound": "नोड नहीं मिला: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "नोड मिला: {{title}}",
|
||||
"panel.nextStep": "अगला कदम",
|
||||
"panel.openWorkflow": "वर्कफ़्लो खोलें",
|
||||
"panel.optional": "(वैकल्पिक)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Pergi ke",
|
||||
"panel.goToFix": "Pergi ke perbaikan",
|
||||
"panel.helpLink": "Docs",
|
||||
"panel.locateNodeNotFound": "Node tidak ditemukan: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Node ditemukan: {{title}}",
|
||||
"panel.nextStep": "Langkah Berikutnya",
|
||||
"panel.openWorkflow": "Buka Alur Kerja",
|
||||
"panel.optional": "(opsional)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Vai a",
|
||||
"panel.goToFix": "Vai a correggere",
|
||||
"panel.helpLink": "Aiuto",
|
||||
"panel.locateNodeNotFound": "Nodo non trovato: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Nodo localizzato: {{title}}",
|
||||
"panel.nextStep": "Prossimo Passo",
|
||||
"panel.openWorkflow": "Apri flusso di lavoro",
|
||||
"panel.optional": "(opzionale)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "移動",
|
||||
"panel.goToFix": "修正する",
|
||||
"panel.helpLink": "ドキュメントを見る",
|
||||
"panel.locateNodeNotFound": "ノードが見つかりません:{{nodeId}}",
|
||||
"panel.locateNodeSuccess": "ノードを特定しました:{{title}}",
|
||||
"panel.nextStep": "次のステップ",
|
||||
"panel.openWorkflow": "ワークフローを開く",
|
||||
"panel.optional": "(任意)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "로 이동",
|
||||
"panel.goToFix": "수정하러 가기",
|
||||
"panel.helpLink": "도움말 센터",
|
||||
"panel.locateNodeNotFound": "노드를 찾을 수 없습니다: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "노드를 찾았습니다: {{title}}",
|
||||
"panel.nextStep": "다음 단계",
|
||||
"panel.openWorkflow": "워크플로 열기",
|
||||
"panel.optional": "(선택사항)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Go to",
|
||||
"panel.goToFix": "Ga naar repareren",
|
||||
"panel.helpLink": "View Docs",
|
||||
"panel.locateNodeNotFound": "Node niet gevonden: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Node gevonden: {{title}}",
|
||||
"panel.nextStep": "Next Step",
|
||||
"panel.openWorkflow": "Open Workflow",
|
||||
"panel.optional": "(optional)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Idź do",
|
||||
"panel.goToFix": "Przejdź do naprawy",
|
||||
"panel.helpLink": "Pomoc",
|
||||
"panel.locateNodeNotFound": "Nie znaleziono węzła: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Zlokalizowano węzeł: {{title}}",
|
||||
"panel.nextStep": "Następny krok",
|
||||
"panel.openWorkflow": "Otwórz przepływ pracy",
|
||||
"panel.optional": "(opcjonalne)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Ir para",
|
||||
"panel.goToFix": "Ir para correção",
|
||||
"panel.helpLink": "Ajuda",
|
||||
"panel.locateNodeNotFound": "Nó não encontrado: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Nó localizado: {{title}}",
|
||||
"panel.nextStep": "Próximo passo",
|
||||
"panel.openWorkflow": "Abrir fluxo de trabalho",
|
||||
"panel.optional": "(opcional)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Du-te la",
|
||||
"panel.goToFix": "Mergi la remediere",
|
||||
"panel.helpLink": "Ajutor",
|
||||
"panel.locateNodeNotFound": "Nod negăsit: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Nod localizat: {{title}}",
|
||||
"panel.nextStep": "Pasul următor",
|
||||
"panel.openWorkflow": "Deschide fluxul de lucru",
|
||||
"panel.optional": "(opțional)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Перейти к",
|
||||
"panel.goToFix": "Перейти к исправлению",
|
||||
"panel.helpLink": "Помощь",
|
||||
"panel.locateNodeNotFound": "Узел не найден: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Узел найден: {{title}}",
|
||||
"panel.nextStep": "Следующий шаг",
|
||||
"panel.openWorkflow": "Открыть рабочий процесс",
|
||||
"panel.optional": "(необязательно)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Pojdi na",
|
||||
"panel.goToFix": "Pojdi na popravi",
|
||||
"panel.helpLink": "Pomoč",
|
||||
"panel.locateNodeNotFound": "Vozlišče ni najdeno: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Vozlišče najdeno: {{title}}",
|
||||
"panel.nextStep": "Naslednji korak",
|
||||
"panel.openWorkflow": "Odpri delovni tok",
|
||||
"panel.optional": "(neobvezno)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "ไปที่",
|
||||
"panel.goToFix": "ไปแก้ไข",
|
||||
"panel.helpLink": "วิธีใช้",
|
||||
"panel.locateNodeNotFound": "ไม่พบโหนด: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "พบโหนดแล้ว: {{title}}",
|
||||
"panel.nextStep": "ขั้นตอนถัดไป",
|
||||
"panel.openWorkflow": "เปิดเวิร์กโฟลว์",
|
||||
"panel.optional": "(ไม่บังคับ)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Git",
|
||||
"panel.goToFix": "Düzeltmeye git",
|
||||
"panel.helpLink": "Yardım",
|
||||
"panel.locateNodeNotFound": "Düğüm bulunamadı: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Düğüm bulundu: {{title}}",
|
||||
"panel.nextStep": "Sonraki Adım",
|
||||
"panel.openWorkflow": "İş Akışını Aç",
|
||||
"panel.optional": "(isteğe bağlı)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Перейти до",
|
||||
"panel.goToFix": "Перейти до виправлення",
|
||||
"panel.helpLink": "Довідковий центр",
|
||||
"panel.locateNodeNotFound": "Вузол не знайдено: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Вузол знайдено: {{title}}",
|
||||
"panel.nextStep": "Наступний крок",
|
||||
"panel.openWorkflow": "Відкрити робочий процес",
|
||||
"panel.optional": "(необов'язково)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "Đi tới",
|
||||
"panel.goToFix": "Đi đến sửa lỗi",
|
||||
"panel.helpLink": "Trung tâm trợ giúp",
|
||||
"panel.locateNodeNotFound": "Không tìm thấy nút: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Đã định vị nút: {{title}}",
|
||||
"panel.nextStep": "Bước tiếp theo",
|
||||
"panel.openWorkflow": "Mở quy trình làm việc",
|
||||
"panel.optional": "(tùy chọn)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "转到",
|
||||
"panel.goToFix": "前往修复",
|
||||
"panel.helpLink": "查看帮助文档",
|
||||
"panel.locateNodeNotFound": "未找到节点:{{nodeId}}",
|
||||
"panel.locateNodeSuccess": "已定位到节点:{{title}}",
|
||||
"panel.nextStep": "下一步",
|
||||
"panel.openWorkflow": "打开工作流",
|
||||
"panel.optional": "(选填)",
|
||||
|
||||
@ -1199,6 +1199,8 @@
|
||||
"panel.goTo": "前往",
|
||||
"panel.goToFix": "前往修復",
|
||||
"panel.helpLink": "查看幫助文件",
|
||||
"panel.locateNodeNotFound": "未找到節點:{{nodeId}}",
|
||||
"panel.locateNodeSuccess": "已定位到節點:{{title}}",
|
||||
"panel.nextStep": "下一步",
|
||||
"panel.openWorkflow": "打開工作流程",
|
||||
"panel.optional": "(選擇性)",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user