test: cover tool auth warning and single-run hooks; add i18n for authorization required

This commit is contained in:
CodingOnStar
2026-03-17 16:18:02 +08:00
parent be366278da
commit 5c53be707c
22 changed files with 1152 additions and 65 deletions

View File

@ -1,9 +1,12 @@
import type { CommonNodeType, Node } from '../../types'
import type { ChecklistItem } from '../use-checklist'
import { screen, waitFor } from '@testing-library/react'
import { createElement, Fragment } from 'react'
import { CollectionType } from '@/app/components/tools/types'
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { renderWorkflowComponent, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useStore } from '../../store'
import { BlockEnum } from '../../types'
import { useChecklist, useWorkflowRunValidation } from '../use-checklist'
@ -363,6 +366,45 @@ describe('useChecklist', () => {
'workflow.errorMsg.invalidVariable',
])
})
it('should sync checklist items to the workflow store without render phase update warnings', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } })
function Operator() {
const checklistItems = useStore(state => state.checklistItems)
return createElement('div', { 'data-testid': 'checklist-count' }, checklistItems.length)
}
function WorkflowChecklist() {
useChecklist([startNode, codeNode], [])
return null
}
const { store } = renderWorkflowComponent(
createElement(
Fragment,
null,
createElement(Operator),
createElement(WorkflowChecklist),
),
)
await waitFor(() => {
expect(store.getState().checklistItems).toHaveLength(1)
})
expect(screen.getByTestId('checklist-count')).toHaveTextContent('1')
expect(errorSpy.mock.calls.some(call =>
call.some(arg => typeof arg === 'string' && arg.includes('Cannot update a component')),
)).toBe(false)
}
finally {
errorSpy.mockRestore()
}
})
})
// ---------------------------------------------------------------------------

View File

@ -17,8 +17,10 @@ import type { Emoji } from '@/app/components/tools/types'
import type { DataSet } from '@/models/datasets'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import { useQueries, useQueryClient } from '@tanstack/react-query'
import isDeepEqual from 'fast-deep-equal'
import {
useCallback,
useEffect,
useMemo,
useRef,
} from 'react'
@ -306,9 +308,16 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
}
})
workflowStore.setState({ checklistItems: list })
return list
}, [nodes, edges, shouldCheckStartNode, nodesExtraData, buildInTools, customTools, workflowTools, mcpTools, language, dataSourceList, triggerPlugins, getToolIcon, strategyProviders, getCheckData, t, map, modelProviders, workflowStore])
}, [nodes, edges, shouldCheckStartNode, nodesExtraData, buildInTools, customTools, workflowTools, mcpTools, language, dataSourceList, triggerPlugins, getToolIcon, strategyProviders, getCheckData, t, map, modelProviders])
useEffect(() => {
const currentChecklistItems = workflowStore.getState().checklistItems
if (isDeepEqual(currentChecklistItems, needWarningNodes))
return
workflowStore.setState({ checklistItems: needWarningNodes })
}, [needWarningNodes, workflowStore])
return needWarningNodes
}

View File

@ -28,8 +28,8 @@ import useQuestionClassifierSingleRunFormParams from '@/app/components/workflow/
import useStartSingleRunFormParams from '@/app/components/workflow/nodes/start/use-single-run-form-params'
import useTemplateTransformSingleRunFormParams from '@/app/components/workflow/nodes/template-transform/use-single-run-form-params'
import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/use-get-data-for-check-more'
import useToolSingleRunFormParams from '@/app/components/workflow/nodes/tool/use-single-run-form-params'
import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/hooks/use-get-data-for-check-more'
import useToolSingleRunFormParams from '@/app/components/workflow/nodes/tool/hooks/use-single-run-form-params'
import useTriggerPluginGetDataForCheckMore from '@/app/components/workflow/nodes/trigger-plugin/use-check-params'
import useVariableAggregatorSingleRunFormParams from '@/app/components/workflow/nodes/variable-assigner/use-single-run-form-params'

View File

@ -0,0 +1,33 @@
import { CollectionType } from '@/app/components/tools/types'
import { isToolAuthorizationRequired } from '../auth'
describe('isToolAuthorizationRequired', () => {
it('should return true for built-in tools that require authorization and are not authorized', () => {
expect(isToolAuthorizationRequired(CollectionType.builtIn, {
allow_delete: true,
is_team_authorization: false,
})).toBe(true)
})
it('should return false when the built-in tool is already authorized', () => {
expect(isToolAuthorizationRequired(CollectionType.builtIn, {
allow_delete: true,
is_team_authorization: true,
})).toBe(false)
})
it('should return false for non-built-in tools even if the provider is unauthorized', () => {
expect(isToolAuthorizationRequired(CollectionType.custom, {
allow_delete: true,
is_team_authorization: false,
})).toBe(false)
})
it('should return false when the collection is missing or authorization is not required', () => {
expect(isToolAuthorizationRequired(CollectionType.builtIn)).toBe(false)
expect(isToolAuthorizationRequired(CollectionType.builtIn, {
allow_delete: false,
is_team_authorization: false,
})).toBe(false)
})
})

View File

@ -0,0 +1,99 @@
import type { ToolNodeType } from '../types'
import { render, screen } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '@/app/components/workflow/types'
import Node from '../node'
const mockUseNodePluginInstallation = vi.hoisted(() => vi.fn())
const mockUseCurrentToolCollection = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({
useNodePluginInstallation: mockUseNodePluginInstallation,
}))
vi.mock('../hooks/use-current-tool-collection', () => ({
__esModule: true,
default: mockUseCurrentToolCollection,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
InstallPluginButton: () => <button type="button">Install Plugin</button>,
}))
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
title: 'Google Search',
desc: '',
type: BlockEnum.Tool,
provider_id: 'google_search',
provider_type: CollectionType.builtIn,
provider_name: 'Google Search',
tool_name: 'google_search',
tool_label: 'Google Search',
tool_parameters: {},
tool_configurations: {},
...overrides,
})
describe('ToolNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseNodePluginInstallation.mockReturnValue({
isChecking: false,
isMissing: false,
uniqueIdentifier: undefined,
canInstall: false,
onInstallSuccess: vi.fn(),
shouldDim: false,
})
mockUseCurrentToolCollection.mockReturnValue({
currentTools: [],
currCollection: undefined,
})
})
describe('Authorization Warning', () => {
it('should render the authorization warning when the tool requires authorization and is not authorized', () => {
mockUseCurrentToolCollection.mockReturnValue({
currentTools: [],
currCollection: {
allow_delete: true,
is_team_authorization: false,
},
})
render(<Node id="tool-node-1" data={createNodeData()} />)
expect(screen.getByText('workflow.nodes.tool.authorizationRequired')).toBeInTheDocument()
})
it('should keep configuration rows visible when the authorization warning is shown', () => {
mockUseCurrentToolCollection.mockReturnValue({
currentTools: [],
currCollection: {
allow_delete: true,
is_team_authorization: false,
},
})
render(
<Node
id="tool-node-1"
data={createNodeData({
tool_configurations: {
region: { value: 'us' },
},
})}
/>,
)
expect(screen.getByText('region')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.tool.authorizationRequired')).toBeInTheDocument()
})
it('should render nothing when there are no configs, no install action and no authorization warning', () => {
const { container } = render(<Node id="tool-node-1" data={createNodeData()} />)
expect(container).toBeEmptyDOMElement()
})
})
})

View File

@ -0,0 +1,309 @@
import type { ReactNode } from 'react'
import type { ToolNodeType } from '../types'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { NodePanelProps } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '@/app/components/workflow/types'
import Panel from '../panel'
const mockUseConfig = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseMatchSchemaType = vi.hoisted(() => vi.fn())
const mockGetMatchedSchemaType = vi.hoisted(() => vi.fn())
const mockWrapStructuredVarItem = vi.hoisted(() => vi.fn())
const mockToolForm = vi.hoisted(() => vi.fn())
const mockStructureOutputItem = vi.hoisted(() => vi.fn())
const mockSplit = vi.hoisted(() => vi.fn())
vi.mock('../hooks/use-config', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseConfig(...args),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: typeof mockWorkflowStoreState) => unknown) => mockUseStore(selector),
}))
vi.mock('../../_base/components/variable/use-match-schema-type', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseMatchSchemaType(...args),
getMatchedSchemaType: (...args: unknown[]) => mockGetMatchedSchemaType(...args),
}))
vi.mock('@/app/components/workflow/utils/tool', () => ({
wrapStructuredVarItem: (...args: unknown[]) => mockWrapStructuredVarItem(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
__esModule: true,
default: (props: {
title?: string
children: ReactNode
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
}) => (
<div data-testid="output-vars">
<div>{props.title ?? 'workflow.nodes.common.outputVars'}</div>
{props.onCollapse && (
<button type="button" onClick={() => props.onCollapse?.(!props.collapsed)}>
toggle-output-vars
</button>
)}
<div>{props.children}</div>
</div>
),
VarItem: (props: {
name: string
type: string
description: string
}) => (
<div data-testid={`var-item-${props.name}`}>
<span>{props.name}</span>
<span>{props.type}</span>
<span>{props.description}</span>
</div>
),
}))
vi.mock('../components/tool-form', () => ({
__esModule: true,
default: (props: {
schema: CredentialFormSchema[]
showManageInputField?: boolean
onManageInputField?: () => void
}) => {
mockToolForm(props)
return (
<div data-testid={`tool-form-${props.schema.map(item => item.variable).join('-') || 'empty'}`}>
{props.showManageInputField && props.onManageInputField && (
<button type="button" onClick={props.onManageInputField}>
Manage Input Field
</button>
)}
</div>
)
},
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show', () => ({
__esModule: true,
default: (props: { payload: { id: string } }) => {
mockStructureOutputItem(props)
return <div data-testid="structured-output">{props.payload.id}</div>
},
}))
vi.mock('../../_base/components/split', () => ({
__esModule: true,
default: (props: { className?: string }) => {
mockSplit(props)
return <div data-testid="split">{props.className ?? 'default'}</div>
},
}))
const mockWorkflowStoreState = {
pipelineId: undefined as string | undefined,
setShowInputFieldPanel: vi.fn(),
}
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
title: 'Google Search',
desc: '',
type: BlockEnum.Tool,
provider_id: 'google_search',
provider_type: CollectionType.builtIn,
provider_name: 'Google Search',
tool_name: 'google_search',
tool_label: 'Google Search',
tool_parameters: {},
tool_configurations: {},
...overrides,
})
const createSchemaItem = (variable: string): CredentialFormSchema => ({
name: variable,
variable,
label: { en_US: variable, zh_Hans: variable },
type: FormTypeEnum.textInput,
required: false,
show_on: [],
})
const renderPanel = (data: ToolNodeType = createNodeData()) => {
const props: NodePanelProps<ToolNodeType> = {
id: 'tool-node-1',
data,
panelProps: {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
},
}
return render(<Panel {...props} />)
}
const createConfigResult = (overrides: Record<string, unknown> = {}) => ({
readOnly: false,
inputs: {
tool_parameters: {},
tool_configurations: {},
},
toolInputVarSchema: [] as CredentialFormSchema[],
setInputVar: vi.fn(),
toolSettingSchema: [] as CredentialFormSchema[],
toolSettingValue: {},
setToolSettingValue: vi.fn(),
currCollection: { name: 'google_search' },
isShowAuthBtn: false,
isLoading: false,
outputSchema: [],
hasObjectOutput: false,
currTool: { name: 'google_search' },
...overrides,
})
describe('ToolPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStoreState.pipelineId = undefined
mockWorkflowStoreState.setShowInputFieldPanel = vi.fn()
mockUseStore.mockImplementation(selector => selector(mockWorkflowStoreState))
mockUseMatchSchemaType.mockReturnValue({
schemaTypeDefinitions: [{ name: 'structured' }],
})
mockGetMatchedSchemaType.mockReturnValue('')
mockWrapStructuredVarItem.mockImplementation((outputItem, schemaType) => ({
id: `${outputItem.name}-${schemaType || 'plain'}`,
}))
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Loading State', () => {
it('should render loading when config data is still loading', () => {
mockUseConfig.mockReturnValue(createConfigResult({
isLoading: true,
}))
renderPanel()
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.tool.inputVars')).not.toBeInTheDocument()
expect(mockToolForm).not.toHaveBeenCalled()
})
})
describe('Form Rendering', () => {
it('should render input and settings forms and forward the manage input field action', () => {
mockWorkflowStoreState.pipelineId = 'pipeline-1'
mockUseConfig.mockReturnValue(createConfigResult({
inputs: {
tool_parameters: { query: { value: 'weather' } },
tool_configurations: {},
},
toolInputVarSchema: [createSchemaItem('query')],
toolSettingSchema: [createSchemaItem('region')],
toolSettingValue: { region: 'us' },
}))
renderPanel()
expect(screen.getByText('workflow.nodes.tool.inputVars')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.tool.settings')).toBeInTheDocument()
expect(screen.getAllByTestId('split')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: 'Manage Input Field' }))
expect(mockToolForm).toHaveBeenCalledTimes(2)
expect(mockToolForm.mock.calls[0][0]).toEqual(expect.objectContaining({
nodeId: 'tool-node-1',
showManageInputField: true,
}))
expect(mockToolForm.mock.calls[1][0]).toEqual(expect.objectContaining({
nodeId: 'tool-node-1',
}))
expect(mockToolForm.mock.calls[1][0]).not.toHaveProperty('showManageInputField')
expect(mockWorkflowStoreState.setShowInputFieldPanel).toHaveBeenCalledWith(true)
})
it('should hide editable forms when the auth button is shown but keep output variables visible', () => {
mockUseConfig.mockReturnValue(createConfigResult({
isShowAuthBtn: true,
toolInputVarSchema: [createSchemaItem('query')],
toolSettingSchema: [createSchemaItem('region')],
}))
renderPanel()
expect(screen.queryByText('workflow.nodes.tool.inputVars')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.tool.settings')).not.toBeInTheDocument()
expect(screen.getByText('text')).toBeInTheDocument()
expect(screen.getByText('files')).toBeInTheDocument()
expect(screen.getByText('json')).toBeInTheDocument()
expect(mockToolForm).not.toHaveBeenCalled()
})
})
describe('Output Schema', () => {
it('should render scalar and structured outputs with matched schema types', () => {
mockGetMatchedSchemaType.mockImplementation((value: { type?: string }) => {
return value?.type === 'string' ? 'qa_structured' : 'object_structured'
})
mockUseConfig.mockReturnValue(createConfigResult({
hasObjectOutput: true,
outputSchema: [
{
name: 'summary',
type: 'String',
description: 'Summary field',
value: { type: 'string' },
},
{
name: 'details',
type: 'Object',
description: 'Details field',
value: { type: 'object', properties: {} },
},
],
}))
renderPanel()
expect(screen.getByText('summary')).toBeInTheDocument()
expect(screen.getByText('string (qa_structured)')).toBeInTheDocument()
expect(screen.getByText('Summary field')).toBeInTheDocument()
expect(screen.getByTestId('structured-output')).toHaveTextContent('details-object_structured')
expect(mockWrapStructuredVarItem).toHaveBeenCalledWith(expect.objectContaining({
name: 'details',
}), 'object_structured')
expect(mockStructureOutputItem).toHaveBeenCalledWith(expect.objectContaining({
payload: { id: 'details-object_structured' },
}))
})
it('should render scalar outputs without a schema suffix when no schema type matches', () => {
mockGetMatchedSchemaType.mockReturnValue('')
mockUseConfig.mockReturnValue(createConfigResult({
outputSchema: [
{
name: 'summary',
type: 'String',
description: 'Summary field',
value: { type: 'string' },
},
],
}))
renderPanel()
expect(screen.getByTestId('var-item-summary')).toHaveTextContent('summary')
expect(screen.getByTestId('var-item-summary')).toHaveTextContent('String'.toLowerCase())
expect(screen.getByTestId('var-item-summary')).not.toHaveTextContent('qa_structured')
})
})
})

View File

@ -0,0 +1,14 @@
import type { ToolWithProvider } from '../../types'
import type { ToolNodeType } from './types'
import { CollectionType } from '@/app/components/tools/types'
type ToolAuthorizationCollection = Pick<ToolWithProvider, 'allow_delete' | 'is_team_authorization'>
export const isToolAuthorizationRequired = (
providerType: ToolNodeType['provider_type'],
collection?: ToolAuthorizationCollection,
) => {
return providerType === CollectionType.builtIn
&& !!collection?.allow_delete
&& collection?.is_team_authorization === false
}

View File

@ -0,0 +1,194 @@
import type { ToolNodeType } from '../../types'
import { renderHook } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { VarType } from '../../types'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockSetControlPromptEditorRerenderKey = vi.hoisted(() => vi.fn())
const mockUseCurrentToolCollection = vi.hoisted(() => vi.fn())
const mockGetConfiguredValue = vi.hoisted(() => vi.fn())
const mockToolParametersToFormSchemas = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (_id: string, data: ToolNodeType) => ({
inputs: data,
setInputs: mockSetInputs,
}),
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
getConfiguredValue: (...args: unknown[]) => mockGetConfiguredValue(...args),
toolParametersToFormSchemas: (...args: unknown[]) => mockToolParametersToFormSchemas(...args),
}))
vi.mock('@/service/tools', () => ({
updateBuiltInToolCredential: vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useInvalidToolsByType: () => vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setControlPromptEditorRerenderKey: mockSetControlPromptEditorRerenderKey,
}),
}),
}))
vi.mock('../use-current-tool-collection', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseCurrentToolCollection(...args),
}))
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
title: 'Google Search',
desc: '',
type: BlockEnum.Tool,
provider_id: 'google_search',
provider_type: CollectionType.builtIn,
provider_name: 'Google Search',
tool_name: 'google_search',
tool_label: 'Google Search',
tool_parameters: {},
tool_configurations: {},
...overrides,
})
const currentTool = {
name: 'google_search',
parameters: [
{
variable: 'query',
form: 'llm',
label: { en_US: 'Query' },
type: 'string',
required: true,
default: 'default query',
},
{
variable: 'api_key',
form: 'credential',
label: { en_US: 'API Key' },
type: 'string',
required: true,
default: 'default secret',
},
],
}
const currentToolWithoutDefaults = {
name: 'google_search',
parameters: [
{
variable: 'query',
form: 'llm',
label: { en_US: 'Query' },
type: 'string',
required: true,
},
{
variable: 'api_key',
form: 'credential',
label: { en_US: 'API Key' },
type: 'string',
required: true,
},
],
}
const createToolVarInput = (value: string) => ({
type: VarType.mixed,
value,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockUseCurrentToolCollection.mockReturnValue({
currCollection: {
name: 'google_search',
allow_delete: true,
is_team_authorization: false,
tools: [currentTool],
},
})
mockToolParametersToFormSchemas.mockImplementation(parameters => parameters)
mockGetConfiguredValue.mockImplementation((_value, schema: Array<{ variable: string, default?: string }>) => {
return schema.reduce<Record<string, ReturnType<typeof createToolVarInput>>>((acc, item) => {
acc[item.variable] = createToolVarInput(item.default || '')
return acc
}, {} as Record<string, ReturnType<typeof createToolVarInput>>)
})
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
describe('Default Value Sync', () => {
it('should apply default values only once when the current payload is initially empty', () => {
const emptyPayload = createNodeData()
const syncedPayload = createNodeData({
tool_parameters: { query: createToolVarInput('default query') },
tool_configurations: { api_key: createToolVarInput('default secret') },
})
const { rerender } = renderHook(
({ payload }) => useConfig('tool-node-1', payload),
{ initialProps: { payload: emptyPayload } },
)
expect(mockSetInputs).toHaveBeenCalledTimes(1)
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
tool_parameters: { query: createToolVarInput('default query') },
tool_configurations: { api_key: createToolVarInput('default secret') },
}))
rerender({ payload: syncedPayload })
expect(mockSetInputs).toHaveBeenCalledTimes(1)
})
it('should not update inputs when tool values are already populated on first render', () => {
renderHook(() => useConfig('tool-node-1', createNodeData({
tool_parameters: { query: createToolVarInput('existing query') },
tool_configurations: { api_key: createToolVarInput('existing secret') },
})))
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should not update inputs when empty schemas do not provide any default values', () => {
mockUseCurrentToolCollection.mockReturnValue({
currCollection: {
name: 'google_search',
allow_delete: true,
is_team_authorization: false,
tools: [currentToolWithoutDefaults],
},
})
mockGetConfiguredValue.mockReturnValue({})
renderHook(() => useConfig('tool-node-1', createNodeData()))
expect(mockSetInputs).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,84 @@
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import useCurrentToolCollection from '../use-current-tool-collection'
const mockUseAllBuiltInTools = vi.hoisted(() => vi.fn())
const mockUseAllCustomTools = vi.hoisted(() => vi.fn())
const mockUseAllWorkflowTools = vi.hoisted(() => vi.fn())
const mockUseAllMCPTools = vi.hoisted(() => vi.fn())
const mockCanFindTool = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => mockUseAllBuiltInTools(),
useAllCustomTools: () => mockUseAllCustomTools(),
useAllWorkflowTools: () => mockUseAllWorkflowTools(),
useAllMCPTools: () => mockUseAllMCPTools(),
}))
vi.mock('@/utils', () => ({
canFindTool: (...args: unknown[]) => mockCanFindTool(...args),
}))
const createToolCollection = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
id: 'builtin-search',
name: 'Google Search',
type: CollectionType.builtIn,
label: { en_US: 'Google Search' },
description: { en_US: 'Search provider' },
icon: '',
icon_dark: '',
background: '',
tags: [],
tools: [],
allow_delete: true,
is_team_authorization: false,
meta: {} as ToolWithProvider['meta'],
...overrides,
}) as ToolWithProvider
describe('useCurrentToolCollection', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanFindTool.mockImplementation((collectionId: string, providerId: string) => collectionId === providerId)
mockUseAllBuiltInTools.mockReturnValue({ data: [] })
mockUseAllCustomTools.mockReturnValue({ data: [] })
mockUseAllWorkflowTools.mockReturnValue({ data: [] })
mockUseAllMCPTools.mockReturnValue({ data: [] })
})
it('should return the built-in collection list and matched provider for built-in tools', () => {
const builtInCollection = createToolCollection({ id: 'builtin-search' })
mockUseAllBuiltInTools.mockReturnValue({ data: [builtInCollection] })
const { result } = renderHook(() => useCurrentToolCollection(CollectionType.builtIn, 'builtin-search'))
expect(result.current.currentTools).toEqual([builtInCollection])
expect(result.current.currCollection).toBe(builtInCollection)
expect(mockCanFindTool).toHaveBeenCalledWith('builtin-search', 'builtin-search')
})
it('should select the custom tool collection when the provider type is custom', () => {
const customCollection = createToolCollection({
id: 'custom-search',
type: CollectionType.custom,
})
mockUseAllCustomTools.mockReturnValue({ data: [customCollection] })
const { result } = renderHook(() => useCurrentToolCollection(CollectionType.custom, 'custom-search'))
expect(result.current.currentTools).toEqual([customCollection])
expect(result.current.currCollection).toBe(customCollection)
})
it('should return undefined when no collection matches the provider id', () => {
mockUseAllBuiltInTools.mockReturnValue({
data: [createToolCollection({ id: 'another-tool' })],
})
const { result } = renderHook(() => useCurrentToolCollection(CollectionType.builtIn, 'builtin-search'))
expect(result.current.currentTools).toHaveLength(1)
expect(result.current.currCollection).toBeUndefined()
})
})

View File

@ -0,0 +1,49 @@
import type { ToolNodeType } from '../../types'
import { renderHook } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '@/app/components/workflow/types'
import useGetDataForCheckMore from '../use-get-data-for-check-more'
const mockUseConfig = vi.hoisted(() => vi.fn())
vi.mock('../use-config', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseConfig(...args),
}))
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
title: 'Google Search',
desc: '',
type: BlockEnum.Tool,
provider_id: 'google_search',
provider_type: CollectionType.builtIn,
provider_name: 'Google Search',
tool_name: 'google_search',
tool_label: 'Google Search',
tool_parameters: {},
tool_configurations: {},
...overrides,
})
describe('useGetDataForCheckMore', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expose the config hook validator as getData', () => {
const getMoreDataForCheckValid = vi.fn(() => ({ provider: 'google' }))
const payload = createNodeData()
mockUseConfig.mockReturnValue({
getMoreDataForCheckValid,
})
const { result } = renderHook(() => useGetDataForCheckMore({
id: 'tool-node-1',
payload,
}))
expect(mockUseConfig).toHaveBeenCalledWith('tool-node-1', payload)
expect(result.current.getData).toBe(getMoreDataForCheckValid)
expect(result.current.getData()).toEqual({ provider: 'google' })
})
})

View File

@ -0,0 +1,206 @@
import type { ToolNodeType, VarType } from '../../types'
import type { InputVar } from '@/app/components/workflow/types'
import type { NodeTracing } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseToolIcon = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
const mockFormatToTracingNodeList = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useToolIcon: (...args: unknown[]) => mockUseToolIcon(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatToTracingNodeList(...args),
}))
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
title: 'Google Search',
desc: '',
type: BlockEnum.Tool,
provider_id: 'google_search',
provider_type: CollectionType.builtIn,
provider_name: 'Google Search',
tool_name: 'google_search',
tool_label: 'Google Search',
tool_parameters: {},
tool_configurations: {},
...overrides,
})
const createInputVar = (variable: InputVar['variable']): InputVar => ({
type: InputVarType.textInput,
label: typeof variable === 'string' ? variable : 'invalid',
variable,
required: false,
})
const createRunResult = (): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'tool-node-1',
node_type: BlockEnum.Tool,
title: 'Google Search',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: 'succeeded',
elapsed_time: 1,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 1,
})
describe('useSingleRunFormParams', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseToolIcon.mockReturnValue('tool-icon')
mockFormatToTracingNodeList.mockReturnValue([{ id: 'formatted-node' }])
mockUseNodeCrud.mockImplementation((_id: string, payload: ToolNodeType) => ({
inputs: payload,
}))
})
describe('Variable Extraction', () => {
it('should build form inputs from variable params and settings and expose dependent vars', () => {
const payload = createNodeData({
tool_parameters: {
query: { type: 'variable' as VarType, value: ['start', 'query'] },
legacy_query: { type: 'variable' as VarType, value: 'legacy.answer' },
constant_query: { type: 'constant' as VarType, value: 'fixed' },
},
tool_configurations: {
prompt: { type: 'mixed' as VarType, value: 'prefix {{#tool.result#}}' },
api_key: { type: 'constant' as VarType, value: 'secret' },
plainText: 'ignored',
},
})
const getInputVars = vi.fn(() => [
createInputVar('#start.query#'),
createInputVar(undefined as unknown as string),
createInputVar('#legacy.answer#'),
])
const { result } = renderHook(() => useSingleRunFormParams({
id: 'tool-node-1',
payload,
runInputData: {},
runInputDataRef: { current: {} },
getInputVars,
setRunInputData: vi.fn(),
toVarInputs: vi.fn(),
runResult: null as unknown as NodeTracing,
}))
expect(getInputVars).toHaveBeenCalledWith([
'{{#start.query#}}',
'{{#legacy.answer#}}',
'prefix {{#tool.result#}}',
])
expect(result.current.forms).toHaveLength(1)
expect(result.current.forms[0].inputs).toEqual([
createInputVar('#start.query#'),
createInputVar(undefined as unknown as string),
createInputVar('#legacy.answer#'),
])
expect(result.current.forms[0].values).toEqual({})
expect(result.current.toolIcon).toBe('tool-icon')
expect(result.current.getDependentVars()).toEqual([
['start', 'query'],
['legacy', 'answer'],
])
expect(result.current.nodeInfo).toBeNull()
})
})
describe('Form Updates', () => {
it('should update form values and forward run input data on change', () => {
const payload = createNodeData({
tool_parameters: {
nullable_constant: { type: 'constant' as VarType, value: null },
query: { type: 'variable' as VarType, value: ['start', 'query'] },
},
})
const getInputVars = vi.fn(() => [createInputVar('#start.query#')])
const setRunInputData = vi.fn()
const { result } = renderHook(() => useSingleRunFormParams({
id: 'tool-node-1',
payload,
runInputData: {},
runInputDataRef: { current: {} },
getInputVars,
setRunInputData,
toVarInputs: vi.fn(),
runResult: null as unknown as NodeTracing,
}))
act(() => {
result.current.forms[0].onChange({
query: 'weather',
tool_parameters: {
nullable_constant: 'temp-value',
},
})
})
expect(setRunInputData).toHaveBeenCalledWith({
query: 'weather',
tool_parameters: {
nullable_constant: 'temp-value',
},
})
expect(result.current.forms[0].values).toEqual({
query: 'weather',
tool_parameters: {
nullable_constant: 'temp-value',
},
nullable_constant: null,
})
})
})
describe('Tracing Data', () => {
it('should format the latest run result into node info when a run result exists', () => {
const payload = createNodeData()
const runResult = createRunResult()
const { result } = renderHook(() => useSingleRunFormParams({
id: 'tool-node-1',
payload,
runInputData: {},
runInputDataRef: { current: {} },
getInputVars: vi.fn(() => []),
setRunInputData: vi.fn(),
toVarInputs: vi.fn(),
runResult,
}))
expect(mockFormatToTracingNodeList).toHaveBeenCalledWith([runResult], expect.any(Function))
expect(result.current.nodeInfo).toEqual({ id: 'formatted-node' })
})
})
})

View File

@ -1,4 +1,4 @@
import type { ToolNodeType, ToolVarInputs } from './types'
import type { ToolNodeType, ToolVarInputs } from '../types'
import type { InputVar } from '@/app/components/workflow/types'
import { useBoolean } from 'ahooks'
import { capitalize } from 'es-toolkit/string'
@ -16,17 +16,14 @@ import {
useNodesReadOnly,
} from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { updateBuiltInToolCredential } from '@/service/tools'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidToolsByType,
} from '@/service/use-tools'
import { canFindTool } from '@/utils'
import { useWorkflowStore } from '../../store'
import { normalizeJsonSchemaType } from './output-schema-utils'
import { isToolAuthorizationRequired } from '../auth'
import { normalizeJsonSchemaType } from '../output-schema-utils'
import useCurrentToolCollection from './use-current-tool-collection'
const formatDisplayType = (output: Record<string, unknown>): string => {
const normalizedType = normalizeJsonSchemaType(output) || 'Unknown'
@ -55,33 +52,10 @@ const useConfig = (id: string, payload: ToolNodeType) => {
tool_parameters,
} = inputs
const isBuiltIn = provider_type === CollectionType.builtIn
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const currentTools = useMemo(() => {
switch (provider_type) {
case CollectionType.builtIn:
return buildInTools || []
case CollectionType.custom:
return customTools || []
case CollectionType.workflow:
return workflowTools || []
case CollectionType.mcp:
return mcpTools || []
default:
return []
}
}, [buildInTools, customTools, mcpTools, provider_type, workflowTools])
const currCollection = useMemo(() => {
return currentTools.find(item => canFindTool(item.id, provider_id))
}, [currentTools, provider_id])
const { currCollection } = useCurrentToolCollection(provider_type, provider_id)
// Auth
const needAuth = !!currCollection?.allow_delete
const isAuthed = !!currCollection?.is_team_authorization
const isShowAuthBtn = isBuiltIn && needAuth && !isAuthed
const isShowAuthBtn = isToolAuthorizationRequired(provider_type, currCollection)
const [
showSetAuth,
{ setTrue: showSetAuthModal, setFalse: hideSetAuthModal },
@ -104,7 +78,6 @@ const useConfig = (id: string, payload: ToolNodeType) => {
hideSetAuthModal,
t,
invalidToolsByType,
provider_type,
],
)
@ -172,38 +145,49 @@ const useConfig = (id: string, payload: ToolNodeType) => {
[inputs, setInputs],
)
const formattingParameters = () => {
const formattingParameters = useCallback(() => {
const inputsWithDefaultValue = produce(inputs, (draft) => {
if (
!draft.tool_configurations
|| Object.keys(draft.tool_configurations).length === 0
) {
draft.tool_configurations = getConfiguredValue(
const configuredToolSettings = getConfiguredValue(
tool_configurations,
toolSettingSchema,
) as ToolVarInputs
if (Object.keys(configuredToolSettings).length > 0)
draft.tool_configurations = configuredToolSettings
}
if (
!draft.tool_parameters
|| Object.keys(draft.tool_parameters).length === 0
) {
draft.tool_parameters = getConfiguredValue(
const configuredToolParameters = getConfiguredValue(
tool_parameters,
toolInputVarSchema,
) as ToolVarInputs
if (Object.keys(configuredToolParameters).length > 0)
draft.tool_parameters = configuredToolParameters
}
})
return inputsWithDefaultValue
}
}, [inputs, toolInputVarSchema, toolSettingSchema, tool_configurations, tool_parameters])
useEffect(() => {
if (!currTool)
return
const inputsWithDefaultValue = formattingParameters()
if (inputsWithDefaultValue === inputs)
return
const { setControlPromptEditorRerenderKey } = workflowStore.getState()
setInputs(inputsWithDefaultValue)
setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
}, [currTool])
const rerenderTimeout = setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
return () => {
clearTimeout(rerenderTimeout)
}
}, [currTool, formattingParameters, inputs, setInputs, workflowStore])
// setting when call
const setInputVar = useCallback(

View File

@ -0,0 +1,47 @@
import type { ToolNodeType } from '../types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { useMemo } from 'react'
import { CollectionType } from '@/app/components/tools/types'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
} from '@/service/use-tools'
import { canFindTool } from '@/utils'
const useCurrentToolCollection = (
providerType: ToolNodeType['provider_type'],
providerId: string,
) => {
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const currentTools = useMemo<ToolWithProvider[]>(() => {
switch (providerType) {
case CollectionType.builtIn:
return buildInTools || []
case CollectionType.custom:
return customTools || []
case CollectionType.workflow:
return workflowTools || []
case CollectionType.mcp:
return mcpTools || []
default:
return []
}
}, [buildInTools, customTools, mcpTools, providerType, workflowTools])
const currCollection = useMemo(() => {
return currentTools.find(item => canFindTool(item.id, providerId))
}, [currentTools, providerId])
return {
currentTools,
currCollection,
}
}
export default useCurrentToolCollection

View File

@ -1,4 +1,4 @@
import type { ToolNodeType } from './types'
import type { ToolNodeType } from '../types'
import useConfig from './use-config'
type Params = {

View File

@ -1,15 +1,15 @@
import type { RefObject } from 'react'
import type { ToolNodeType } from './types'
import type { ToolNodeType } from '../types'
import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form'
import type { InputVar, ValueSelector, Variable } from '@/app/components/workflow/types'
import type { NodeTracing } from '@/types/workflow'
import { produce } from 'immer'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useToolIcon } from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import formatToTracingNodeList from '@/app/components/workflow/run/utils/format-log'
import { useToolIcon } from '../../hooks'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { VarType } from './types'
import { VarType } from '../types'
type Params = {
id: string
@ -50,9 +50,9 @@ const useSingleRunFormParams = ({
return p.value as string
}))
const [inputVarValues, doSetInputVarValues] = useState<Record<string, any>>({})
const setInputVarValues = useCallback((value: Record<string, any>) => {
doSetInputVarValues(value)
const [inputVarValues, setInputVarValues] = useState<Record<string, any>>({})
const handleInputVarValuesChange = useCallback((value: Record<string, any>) => {
setInputVarValues(value)
setRunInputData(value)
}, [setRunInputData])
@ -74,10 +74,10 @@ const useSingleRunFormParams = ({
const forms: FormProps[] = [{
inputs: varInputs,
values: inputVarValuesWithConstantValue(),
onChange: setInputVarValues,
onChange: handleInputVarValuesChange,
}]
return forms
}, [inputVarValuesWithConstantValue, setInputVarValues, varInputs])
}, [handleInputVarValuesChange, inputVarValuesWithConstantValue, varInputs])
const nodeInfo = useMemo(() => {
if (!runResult)

View File

@ -2,13 +2,17 @@ import type { FC } from 'react'
import type { ToolNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
import { isToolAuthorizationRequired } from './auth'
import useCurrentToolCollection from './hooks/use-current-tool-collection'
const Node: FC<NodeProps<ToolNodeType>> = ({
data,
}) => {
const { t } = useTranslation()
const { tool_configurations, paramSchemas } = data
const toolConfigs = Object.keys(tool_configurations || {})
const {
@ -19,11 +23,13 @@ const Node: FC<NodeProps<ToolNodeType>> = ({
onInstallSuccess,
shouldDim,
} = useNodePluginInstallation(data)
const { currCollection } = useCurrentToolCollection(data.provider_type, data.provider_id)
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
const showAuthorizationWarning = isToolAuthorizationRequired(data.provider_type, currCollection)
const hasConfigs = toolConfigs.length > 0
if (!showInstallButton && !hasConfigs)
if (!showInstallButton && !hasConfigs && !showAuthorizationWarning)
return null
return (
@ -43,10 +49,10 @@ const Node: FC<NodeProps<ToolNodeType>> = ({
/>
</div>
)}
{hasConfigs && (
{(hasConfigs || showAuthorizationWarning) && (
<div className="space-y-0.5" aria-disabled={shouldDim}>
{toolConfigs.map((key, index) => (
<div key={index} className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary">
{hasConfigs && toolConfigs.map(key => (
<div key={key} className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary">
<div title={key} className="max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary">
{key}
</div>
@ -67,6 +73,14 @@ const Node: FC<NodeProps<ToolNodeType>> = ({
)}
</div>
))}
{showAuthorizationWarning && (
<div className="flex h-6 items-center rounded-md border-[0.5px] border-state-warning-active bg-state-warning-hover px-1.5">
<span className="mr-1 size-[4px] shrink-0 rounded-[2px] bg-text-warning-secondary" />
<div className="grow truncate text-text-warning system-xs-medium" title={t('nodes.tool.authorizationRequired', { ns: 'workflow' })}>
{t('nodes.tool.authorizationRequired', { ns: 'workflow' })}
</div>
</div>
)}
</div>
)}
</div>

View File

@ -12,7 +12,7 @@ import { wrapStructuredVarItem } from '@/app/components/workflow/utils/tool'
import Split from '../_base/components/split'
import useMatchSchemaType, { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type'
import ToolForm from './components/tool-form'
import useConfig from './use-config'
import useConfig from './hooks/use-config'
const i18nPrefix = 'nodes.tool'

View File

@ -7,6 +7,7 @@ import type { StructuredOutput } from '@/app/components/workflow/nodes/llm/types
import { CollectionType } from '@/app/components/tools/types'
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { isToolAuthorizationRequired } from '@/app/components/workflow/nodes/tool/auth'
import { canFindTool } from '@/utils'
export const getToolCheckParams = (
@ -17,7 +18,6 @@ export const getToolCheckParams = (
language: string,
) => {
const { provider_id, provider_type, tool_name } = toolData
const isBuiltIn = provider_type === CollectionType.builtIn
const currentTools = provider_type === CollectionType.builtIn ? buildInTools : provider_type === CollectionType.custom ? customTools : workflowTools
const currCollection = currentTools.find(item => canFindTool(item.id, provider_id))
const currTool = currCollection?.tools.find(tool => tool.name === tool_name)
@ -38,7 +38,7 @@ export const getToolCheckParams = (
})
return formInputs
})(),
notAuthed: isBuiltIn && !!currCollection?.allow_delete && !currCollection?.is_team_authorization,
notAuthed: isToolAuthorizationRequired(provider_type, currCollection),
toolSettingSchema,
language,
}