test(web): enhance unit tests for Icon and AppPicker components

- Refactored and improved test cases for the Icon component, ensuring proper rendering and behavior for various src configurations.
- Updated AppPicker tests to utilize React context for managing portal open state, enhancing test reliability and clarity.
- Adjusted type definitions in several components to use more specific types, improving type safety and code maintainability.
This commit is contained in:
CodingOnStar
2026-01-18 13:49:29 +08:00
parent 2d8dcbc0d8
commit 89f5c27edc
13 changed files with 260 additions and 242 deletions

View File

@ -998,58 +998,59 @@ describe('Icon', () => {
render(<Icon src={{ content: '', background: '#ffffff' }} />)
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
it('should not render status indicators when src is object with installed=true', () => {
render(<Icon src={{ content: '🎉', background: '#fff' }} installed={true} />)
})
// Status indicators should not render for object src
expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument()
})
it('should not render status indicators when src is object with installed=true', () => {
render(<Icon src={{ content: '🎉', background: '#fff' }} installed={true} />)
it('should not render status indicators when src is object with installFailed=true', () => {
render(<Icon src={{ content: '🎉', background: '#fff' }} installFailed={true} />)
// Status indicators should not render for object src
expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument()
})
// Status indicators should not render for object src
expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument()
})
it('should not render status indicators when src is object with installFailed=true', () => {
render(<Icon src={{ content: '🎉', background: '#fff' }} installFailed={true} />)
it('should render object src with all size variants', () => {
const sizes: Array<'xs' | 'tiny' | 'small' | 'medium' | 'large'> = ['xs', 'tiny', 'small', 'medium', 'large']
// Status indicators should not render for object src
expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument()
})
sizes.forEach((size) => {
const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} size={size} />)
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size)
unmount()
})
})
it('should render object src with all size variants', () => {
const sizes: Array<'xs' | 'tiny' | 'small' | 'medium' | 'large'> = ['xs', 'tiny', 'small', 'medium', 'large']
it('should render object src with custom className', () => {
const { container } = render(
<Icon src={{ content: '🎉', background: '#fff' }} className="custom-object-icon" />,
)
expect(container.querySelector('.custom-object-icon')).toBeInTheDocument()
})
it('should pass correct props to AppIcon for object src', () => {
render(<Icon src={{ content: '😀', background: '#123456' }} />)
const appIcon = screen.getByTestId('app-icon')
expect(appIcon).toHaveAttribute('data-icon', '😀')
expect(appIcon).toHaveAttribute('data-background', '#123456')
expect(appIcon).toHaveAttribute('data-icon-type', 'emoji')
})
it('should render inner icon only when shouldUseMcpIcon returns true', () => {
// Test with MCP icon content
const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} />)
expect(screen.getByTestId('inner-icon')).toBeInTheDocument()
sizes.forEach((size) => {
const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} size={size} />)
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size)
unmount()
// Test without MCP icon content
render(<Icon src={{ content: '🎉', background: '#fff' }} />)
expect(screen.queryByTestId('inner-icon')).not.toBeInTheDocument()
})
})
it('should render object src with custom className', () => {
const { container } = render(
<Icon src={{ content: '🎉', background: '#fff' }} className="custom-object-icon" />,
)
expect(container.querySelector('.custom-object-icon')).toBeInTheDocument()
})
it('should pass correct props to AppIcon for object src', () => {
render(<Icon src={{ content: '😀', background: '#123456' }} />)
const appIcon = screen.getByTestId('app-icon')
expect(appIcon).toHaveAttribute('data-icon', '😀')
expect(appIcon).toHaveAttribute('data-background', '#123456')
expect(appIcon).toHaveAttribute('data-icon-type', 'emoji')
})
it('should render inner icon only when shouldUseMcpIcon returns true', () => {
// Test with MCP icon content
const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} />)
expect(screen.getByTestId('inner-icon')).toBeInTheDocument()
unmount()
// Test without MCP icon content
render(<Icon src={{ content: '🎉', background: '#fff' }} />)
expect(screen.queryByTestId('inner-icon')).not.toBeInTheDocument()
})
})
// ================================

View File

@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import type { App } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
@ -9,6 +10,7 @@ import AppInputsForm from './app-inputs-form'
import AppInputsPanel from './app-inputs-panel'
import AppPicker from './app-picker'
import AppTrigger from './app-trigger'
import AppSelector from './index'
// ==================== Mock Setup ====================
@ -73,44 +75,59 @@ afterAll(() => {
})
// Mock portal components for controlled positioning in tests
let mockPortalOpenState = false
// Use React context to properly scope open state per portal instance (for nested portals)
const _PortalOpenContext = React.createContext(false)
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
open,
}: {
children: ReactNode
open?: boolean
}) => {
mockPortalOpenState = open || false
return (
<div data-testid="portal-to-follow-elem" data-open={open}>
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
// Context reference shared across mock components
let sharedContext: React.Context<boolean> | null = null
// Lazily get or create the context
const getContext = (): React.Context<boolean> => {
if (!sharedContext)
sharedContext = React.createContext(false)
return sharedContext
}
return {
PortalToFollowElem: ({
children,
open,
}: {
children: ReactNode
open?: boolean
}) => {
const Context = getContext()
return React.createElement(
Context.Provider,
{ value: open || false },
React.createElement('div', { 'data-testid': 'portal-to-follow-elem', 'data-open': open }, children),
)
},
PortalToFollowElemTrigger: ({
children,
onClick,
className,
}: {
children: ReactNode
onClick?: () => void
className?: string
}) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
</div>
)
},
PortalToFollowElemTrigger: ({
children,
onClick,
className,
}: {
children: ReactNode
onClick?: () => void
className?: string
}) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: ReactNode, className?: string }) => {
if (!mockPortalOpenState)
return null
return (
<div data-testid="portal-content" className={className}>{children}</div>
)
},
}))
),
PortalToFollowElemContent: ({ children, className }: { children: ReactNode, className?: string }) => {
const Context = getContext()
const isOpen = React.useContext(Context)
if (!isOpen)
return null
return (
<div data-testid="portal-content" className={className}>{children}</div>
)
},
}
})
// Mock service hooks
let mockAppListData: { pages: Array<{ data: App[], has_more: boolean, page: number }> } | undefined
@ -129,10 +146,12 @@ const getAppDetailData = (appId: string) => {
return mockAppDetailData
if (!appId)
return undefined
// Extract number from appId (e.g., 'app-1' -> '1') for consistent naming with createMockApps
const appNumber = appId.replace('app-', '')
// Return a basic mock app structure
return {
id: appId,
name: `App ${appId}`,
name: `App ${appNumber}`,
mode: 'chat',
icon_type: 'emoji',
icon: '🤖',
@ -345,20 +364,25 @@ const createMockApp = (overrides: Record<string, unknown> = {}): App => ({
...overrides,
} as unknown as App)
// Helper function to get app mode based on index
const getAppModeByIndex = (index: number): AppModeEnum => {
if (index % 5 === 0)
return AppModeEnum.ADVANCED_CHAT
if (index % 4 === 0)
return AppModeEnum.AGENT_CHAT
if (index % 3 === 0)
return AppModeEnum.WORKFLOW
if (index % 2 === 0)
return AppModeEnum.COMPLETION
return AppModeEnum.CHAT
}
const createMockApps = (count: number): App[] => {
return Array.from({ length: count }, (_, i) =>
createMockApp({
id: `app-${i + 1}`,
name: `App ${i + 1}`,
mode: i % 5 === 0
? AppModeEnum.ADVANCED_CHAT
: i % 4 === 0
? AppModeEnum.AGENT_CHAT
: i % 3 === 0
? AppModeEnum.WORKFLOW
: i % 2 === 0
? AppModeEnum.COMPLETION
: AppModeEnum.CHAT,
mode: getAppModeByIndex(i),
}))
}
@ -446,7 +470,6 @@ describe('AppPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockPortalOpenState = false
})
afterEach(() => {
@ -460,14 +483,12 @@ describe('AppPicker', () => {
})
it('should render app list when open', () => {
mockPortalOpenState = true
render(<AppPicker {...defaultProps} isShow={true} />)
expect(screen.getByText('App 1')).toBeInTheDocument()
expect(screen.getByText('App 2')).toBeInTheDocument()
})
it('should show loading indicator when isLoading is true', () => {
mockPortalOpenState = true
render(<AppPicker {...defaultProps} isShow={true} isLoading={true} />)
expect(screen.getByText('common.loading')).toBeInTheDocument()
})
@ -480,7 +501,6 @@ describe('AppPicker', () => {
describe('User Interactions', () => {
it('should call onSelect when app is clicked', () => {
mockPortalOpenState = true
const onSelect = vi.fn()
render(<AppPicker {...defaultProps} isShow={true} onSelect={onSelect} />)
@ -489,7 +509,6 @@ describe('AppPicker', () => {
})
it('should call onSearchChange when typing in search input', () => {
mockPortalOpenState = true
const onSearchChange = vi.fn()
render(<AppPicker {...defaultProps} isShow={true} onSearchChange={onSearchChange} />)
@ -517,35 +536,30 @@ describe('AppPicker', () => {
describe('App Type Display', () => {
it('should display correct app type for CHAT', () => {
mockPortalOpenState = true
const apps = [createMockApp({ id: 'chat-app', name: 'Chat App', mode: AppModeEnum.CHAT })]
render(<AppPicker {...defaultProps} isShow={true} apps={apps} />)
expect(screen.getByText('chat')).toBeInTheDocument()
})
it('should display correct app type for WORKFLOW', () => {
mockPortalOpenState = true
const apps = [createMockApp({ id: 'workflow-app', name: 'Workflow App', mode: AppModeEnum.WORKFLOW })]
render(<AppPicker {...defaultProps} isShow={true} apps={apps} />)
expect(screen.getByText('workflow')).toBeInTheDocument()
})
it('should display correct app type for ADVANCED_CHAT', () => {
mockPortalOpenState = true
const apps = [createMockApp({ id: 'chatflow-app', name: 'Chatflow App', mode: AppModeEnum.ADVANCED_CHAT })]
render(<AppPicker {...defaultProps} isShow={true} apps={apps} />)
expect(screen.getByText('chatflow')).toBeInTheDocument()
})
it('should display correct app type for AGENT_CHAT', () => {
mockPortalOpenState = true
const apps = [createMockApp({ id: 'agent-app', name: 'Agent App', mode: AppModeEnum.AGENT_CHAT })]
render(<AppPicker {...defaultProps} isShow={true} apps={apps} />)
expect(screen.getByText('agent')).toBeInTheDocument()
})
it('should display correct app type for COMPLETION', () => {
mockPortalOpenState = true
const apps = [createMockApp({ id: 'completion-app', name: 'Completion App', mode: AppModeEnum.COMPLETION })]
render(<AppPicker {...defaultProps} isShow={true} apps={apps} />)
expect(screen.getByText('completion')).toBeInTheDocument()
@ -554,13 +568,11 @@ describe('AppPicker', () => {
describe('Edge Cases', () => {
it('should handle empty apps array', () => {
mockPortalOpenState = true
render(<AppPicker {...defaultProps} isShow={true} apps={[]} />)
expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
})
it('should handle search text with value', () => {
mockPortalOpenState = true
render(<AppPicker {...defaultProps} isShow={true} searchText="test search" />)
const input = screen.getByTestId('input')
expect(input).toHaveValue('test search')
@ -569,7 +581,6 @@ describe('AppPicker', () => {
describe('Search Clear', () => {
it('should call onSearchChange with empty string when clear button is clicked', () => {
mockPortalOpenState = true
const onSearchChange = vi.fn()
render(<AppPicker {...defaultProps} isShow={true} searchText="test" onSearchChange={onSearchChange} />)
@ -581,7 +592,6 @@ describe('AppPicker', () => {
describe('Infinite Scroll', () => {
it('should not call onLoadMore when isLoading is true', () => {
mockPortalOpenState = true
const onLoadMore = vi.fn()
render(<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={true} onLoadMore={onLoadMore} />)
@ -594,7 +604,6 @@ describe('AppPicker', () => {
})
it('should not call onLoadMore when hasMore is false', () => {
mockPortalOpenState = true
const onLoadMore = vi.fn()
render(<AppPicker {...defaultProps} isShow={true} hasMore={false} onLoadMore={onLoadMore} />)
@ -607,7 +616,6 @@ describe('AppPicker', () => {
})
it('should call onLoadMore when intersection observer fires and conditions are met', () => {
mockPortalOpenState = true
const onLoadMore = vi.fn()
render(<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />)
@ -619,7 +627,6 @@ describe('AppPicker', () => {
})
it('should not call onLoadMore when target is not intersecting', () => {
mockPortalOpenState = true
const onLoadMore = vi.fn()
render(<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />)
@ -631,7 +638,6 @@ describe('AppPicker', () => {
})
it('should handle observer target ref', () => {
mockPortalOpenState = true
render(<AppPicker {...defaultProps} isShow={true} hasMore={true} />)
// The component should render without errors
@ -642,11 +648,9 @@ describe('AppPicker', () => {
const { rerender } = render(<AppPicker {...defaultProps} isShow={false} />)
// Change isShow to true
mockPortalOpenState = true
rerender(<AppPicker {...defaultProps} isShow={true} />)
// Then back to false
mockPortalOpenState = false
rerender(<AppPicker {...defaultProps} isShow={false} />)
// Should not crash
@ -654,8 +658,6 @@ describe('AppPicker', () => {
})
it('should setup intersection observer when isShow is true', () => {
mockPortalOpenState = true
render(<AppPicker {...defaultProps} isShow={true} hasMore={true} />)
// IntersectionObserver callback should have been set
@ -663,14 +665,12 @@ describe('AppPicker', () => {
})
it('should disconnect observer when isShow changes from true to false', () => {
mockPortalOpenState = true
const { rerender } = render(<AppPicker {...defaultProps} isShow={true} />)
// Verify observer was set up
expect(intersectionObserverCallback).not.toBeNull()
// Change to not shown - should disconnect observer (lines 74-75)
mockPortalOpenState = false
rerender(<AppPicker {...defaultProps} isShow={false} />)
// Component should render without errors
@ -678,7 +678,6 @@ describe('AppPicker', () => {
})
it('should cleanup observer on component unmount', () => {
mockPortalOpenState = true
const { unmount } = render(<AppPicker {...defaultProps} isShow={true} />)
// Unmount should trigger cleanup without throwing
@ -686,8 +685,6 @@ describe('AppPicker', () => {
})
it('should handle MutationObserver callback when target becomes available', () => {
mockPortalOpenState = true
render(<AppPicker {...defaultProps} isShow={true} hasMore={true} />)
// Trigger MutationObserver callback (simulates DOM change)
@ -699,8 +696,6 @@ describe('AppPicker', () => {
it('should not setup IntersectionObserver when observerTarget is null', () => {
// When isShow is false, the observer target won't be in the DOM
mockPortalOpenState = false
render(<AppPicker {...defaultProps} isShow={false} />)
// The guard at line 84 should prevent setup
@ -708,7 +703,6 @@ describe('AppPicker', () => {
})
it('should debounce onLoadMore calls using loadingRef', () => {
mockPortalOpenState = true
const onLoadMore = vi.fn()
render(<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />)
@ -1430,7 +1424,6 @@ describe('AppSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockPortalOpenState = false
mockAppListData = {
pages: [{ data: createMockApps(5), has_more: false, page: 1 }],
}
@ -2095,7 +2088,6 @@ describe('AppSelector Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockPortalOpenState = false
mockAppListData = {
pages: [{ data: createMockApps(5), has_more: false, page: 1 }],
}

View File

@ -23,8 +23,8 @@ const PAGE_SIZE = 20
type Props = {
value?: {
app_id: string
inputs: Record<string, any>
files?: any[]
inputs: Record<string, unknown>
files?: unknown[]
}
scope?: string
disabled?: boolean
@ -32,8 +32,8 @@ type Props = {
offset?: OffsetOptions
onSelect: (app: {
app_id: string
inputs: Record<string, any>
files?: any[]
inputs: Record<string, unknown>
files?: unknown[]
}) => void
supportAddCustomTool?: boolean
}
@ -63,12 +63,12 @@ const AppSelector: FC<Props> = ({
name: searchText,
})
const pages = data?.pages ?? []
const displayedApps = useMemo(() => {
const pages = data?.pages ?? []
if (!pages.length)
return []
return pages.flatMap(({ data: apps }) => apps)
}, [pages])
}, [data?.pages])
// fetch selected app by id to avoid pagination gaps
const { data: selectedAppDetail } = useAppDetail(value?.app_id || '')
@ -130,7 +130,7 @@ const AppSelector: FC<Props> = ({
setIsShowChooseApp(false)
}
const handleFormChange = (inputs: Record<string, any>) => {
const handleFormChange = (inputs: Record<string, unknown>) => {
const newFiles = inputs['#image#']
delete inputs['#image#']
const newValue = {

View File

@ -6,6 +6,7 @@ import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types'
import type {
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import {
RiArrowRightUpLine,
@ -34,9 +35,21 @@ import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import SchemaModal from './schema-modal'
type ReasoningConfigInputValue = {
type?: VarKindType
value?: unknown
} | null
type ReasoningConfigInput = {
value: ReasoningConfigInputValue
auto?: 0 | 1
}
export type ReasoningConfigValue = Record<string, ReasoningConfigInput>
type Props = {
value: Record<string, any>
onChange: (val: Record<string, any>) => void
value: ReasoningConfigValue
onChange: (val: ReasoningConfigValue) => void
schemas: ToolFormSchema[]
nodeOutputVars: NodeOutPutVar[]
availableNodes: Node[]
@ -62,7 +75,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
return VarKindType.mixed
}
const handleAutomatic = (key: string, val: any, type: string) => {
const handleAutomatic = (key: string, val: boolean, type: string) => {
onChange({
...value,
[key]: {
@ -71,7 +84,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
},
})
}
const handleTypeChange = useCallback((variable: string, defaultValue: any) => {
const handleTypeChange = useCallback((variable: string, defaultValue: unknown) => {
return (newType: VarKindType) => {
const res = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = {
@ -83,7 +96,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
}
}, [onChange, value])
const handleValueChange = useCallback((variable: string, varType: string) => {
return (newValue: any) => {
return (newValue: unknown) => {
const res = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = {
type: getVarKindType(varType),
@ -96,22 +109,23 @@ const ReasoningConfigForm: React.FC<Props> = ({
const handleAppChange = useCallback((variable: string) => {
return (app: {
app_id: string
inputs: Record<string, any>
files?: any[]
inputs: Record<string, unknown>
files?: unknown[]
}) => {
const newValue = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = app as any
draft[variable].value = app
})
onChange(newValue)
}
}, [onChange, value])
const handleModelChange = useCallback((variable: string) => {
return (model: any) => {
return (model: Record<string, unknown>) => {
const newValue = produce(value, (draft: ToolVarInputs) => {
const currentValue = draft[variable].value as Record<string, unknown> | undefined
draft[variable].value = {
...draft[variable].value,
...currentValue,
...model,
} as any
}
})
onChange(newValue)
}
@ -196,17 +210,17 @@ const ReasoningConfigForm: React.FC<Props> = ({
}
const getFilterVar = () => {
if (isNumber)
return (varPayload: any) => varPayload.type === VarType.number
return (varPayload: Var) => varPayload.type === VarType.number
else if (isString)
return (varPayload: any) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
else if (isFile)
return (varPayload: any) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
else if (isBoolean)
return (varPayload: any) => varPayload.type === VarType.boolean
return (varPayload: Var) => varPayload.type === VarType.boolean
else if (isObject)
return (varPayload: any) => varPayload.type === VarType.object
return (varPayload: Var) => varPayload.type === VarType.object
else if (isArray)
return (varPayload: any) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return undefined
}
@ -266,7 +280,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
<Input
className="h-8 grow"
type="number"
value={varInput?.value || ''}
value={(varInput?.value as string | number) || ''}
onChange={e => handleValueChange(variable, type)(e.target.value)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
@ -280,10 +294,10 @@ const ReasoningConfigForm: React.FC<Props> = ({
{isSelect && options && (
<SimpleSelect
wrapperClassName="h-8 grow"
defaultValue={varInput?.value}
defaultValue={varInput?.value as string | number | undefined}
items={options.filter((option) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value)
return true
}).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
@ -295,7 +309,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
<div className="mt-1 w-full">
<CodeEditor
title="JSON"
value={varInput?.value as any}
value={varInput?.value as string}
isExpand
isInNode
height={100}
@ -310,7 +324,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
<AppSelector
disabled={false}
scope={scope || 'all'}
value={varInput as any}
value={varInput as { app_id: string, inputs: Record<string, unknown>, files?: unknown[] } | undefined}
onSelect={handleAppChange(variable)}
/>
)}
@ -331,7 +345,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
readonly={false}
isShowNodeName
nodeId={nodeId}
value={varInput?.value || []}
value={(varInput?.value as string | ValueSelector) || []}
onChange={handleVariableSelectorChange(variable)}
filterVar={getFilterVar()}
schema={schema as Partial<CredentialFormSchema>}

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { Collection } from '@/app/components/tools/types'
import type { ToolCredentialFormSchema } from '@/app/components/tools/utils/to-form-schema'
import {
RiArrowRightUpLine,
} from '@remixicon/react'
@ -19,7 +20,7 @@ import { cn } from '@/utils/classnames'
type Props = {
collection: Collection
onCancel: () => void
onSaved: (value: Record<string, any>) => void
onSaved: (value: Record<string, unknown>) => void
}
const ToolCredentialForm: FC<Props> = ({
@ -29,9 +30,9 @@ const ToolCredentialForm: FC<Props> = ({
}) => {
const getValueFromI18nObject = useRenderI18nObject()
const { t } = useTranslation()
const [credentialSchema, setCredentialSchema] = useState<any>(null)
const [credentialSchema, setCredentialSchema] = useState<ToolCredentialFormSchema[] | null>(null)
const { name: collectionName } = collection
const [tempCredential, setTempCredential] = React.useState<any>({})
const [tempCredential, setTempCredential] = React.useState<Record<string, unknown>>({})
useEffect(() => {
fetchBuiltInToolCredentialSchema(collectionName).then(async (res) => {
const toolCredentialSchemas = toolCredentialToFormSchemas(res)
@ -44,6 +45,8 @@ const ToolCredentialForm: FC<Props> = ({
}, [])
const handleSave = () => {
if (!credentialSchema)
return
for (const field of credentialSchema) {
if (field.required && !tempCredential[field.name]) {
Toast.notify({ type: 'error', message: t('errorMsg.fieldRequired', { ns: 'common', field: getValueFromI18nObject(field.label) }) })

View File

@ -22,7 +22,7 @@ import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/compo
import { cn } from '@/utils/classnames'
type Props = {
icon?: any
icon?: string | { content?: string, background?: string }
providerName?: string
isMCPTool?: boolean
providerShowName?: string
@ -33,7 +33,7 @@ type Props = {
onDelete?: () => void
noAuth?: boolean
isError?: boolean
errorTip?: any
errorTip?: React.ReactNode
uninstalled?: boolean
installInfo?: string
onInstall?: () => void

View File

@ -2,9 +2,11 @@
import type { FC } from 'react'
import type { Node } from 'reactflow'
import type { TabType } from '../hooks/use-tool-selector-state'
import type { ReasoningConfigValue } from './reasoning-config-form'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types'
import type { NodeOutPutVar, ToolWithProvider } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
@ -19,15 +21,15 @@ type ToolSettingsPanelProps = {
currType: TabType
settingsFormSchemas: ToolFormSchema[]
paramsFormSchemas: ToolFormSchema[]
settingsValue: Record<string, any>
settingsValue: ToolVarInputs
showTabSlider: boolean
userSettingsOnly: boolean
reasoningConfigOnly: boolean
nodeOutputVars: NodeOutPutVar[]
availableNodes: Node[]
onCurrTypeChange: (type: TabType) => void
onSettingsFormChange: (v: Record<string, any>) => void
onParamsFormChange: (v: Record<string, any>) => void
onSettingsFormChange: (v: ToolVarInputs) => void
onParamsFormChange: (v: ReasoningConfigValue) => void
}
/**
@ -140,7 +142,7 @@ const ToolSettingsPanel: FC<ToolSettingsPanelProps> = ({
{/* Reasoning config form */}
{nodeId && (currType === 'params' || reasoningConfigOnly) && (
<ReasoningConfigForm
value={value?.parameters || {}}
value={(value?.parameters || {}) as ReasoningConfigValue}
onChange={onParamsFormChange}
schemas={paramsFormSchemas}
nodeOutputVars={nodeOutputVars}

View File

@ -1,5 +1,8 @@
'use client'
import type { ReasoningConfigValue } from '../components/reasoning-config-form'
import type { ToolParameter } from '@/app/components/tools/types'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ResourceVarInputs } from '@/app/components/workflow/nodes/_base/types'
import { useCallback, useMemo, useState } from 'react'
import { generateFormValue, getPlainValue, getStructureValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
@ -107,11 +110,11 @@ export const useToolSelectorState = ({
const getToolValue = useCallback((tool: ToolDefaultValue): ToolValue => {
const settingValues = generateFormValue(
tool.params,
toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form !== 'llm') as any),
toolParametersToFormSchemas((tool.paramSchemas as ToolParameter[]).filter(param => param.form !== 'llm')),
)
const paramValues = generateFormValue(
tool.params,
toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form === 'llm') as any),
toolParametersToFormSchemas((tool.paramSchemas as ToolParameter[]).filter(param => param.form === 'llm')),
true,
)
return {
@ -152,7 +155,7 @@ export const useToolSelectorState = ({
})
}, [value, onSelect])
const handleSettingsFormChange = useCallback((v: Record<string, any>) => {
const handleSettingsFormChange = useCallback((v: ResourceVarInputs) => {
if (!value)
return
const newValue = getStructureValue(v)
@ -162,7 +165,7 @@ export const useToolSelectorState = ({
})
}, [value, onSelect])
const handleParamsFormChange = useCallback((v: Record<string, any>) => {
const handleParamsFormChange = useCallback((v: ReasoningConfigValue) => {
if (!value)
return
onSelect({
@ -204,8 +207,8 @@ export const useToolSelectorState = ({
}
}, [invalidateAllBuiltinTools, invalidateInstalledPluginList])
const getSettingsValue = useCallback(() => {
return getPlainValue(value?.settings || {})
const getSettingsValue = useCallback((): ResourceVarInputs => {
return getPlainValue((value?.settings || {}) as Record<string, { value: unknown }>) as ResourceVarInputs
}, [value?.settings])
return {

View File

@ -8,6 +8,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CollectionType } from '@/app/components/tools/types'
import { VarKindType } from '@/app/components/workflow/nodes/_base/types'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import {
SchemaModal,
@ -556,7 +557,7 @@ describe('useToolSelectorState Hook', () => {
)
act(() => {
result.current.handleSettingsFormChange({ key: 'value' })
result.current.handleSettingsFormChange({ key: { type: VarKindType.constant, value: 'value' } })
})
expect(onSelect).toHaveBeenCalledWith(
@ -575,11 +576,11 @@ describe('useToolSelectorState Hook', () => {
)
act(() => {
result.current.handleParamsFormChange({ param: 'value' })
result.current.handleParamsFormChange({ param: { value: { type: VarKindType.constant, value: 'value' } } })
})
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ parameters: { param: 'value' } }),
expect.objectContaining({ parameters: { param: { value: { type: VarKindType.constant, value: 'value' } } } }),
)
})

View File

@ -5,6 +5,35 @@ import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
// Type for form value input with type and value properties
type FormValueInput = {
type?: string
value?: unknown
}
/**
* Form schema type for tool credentials.
* This type represents the schema returned by toolCredentialToFormSchemas.
*/
export type ToolCredentialFormSchema = {
name: string
variable: string
label: TypeWithI18N
type: string
required: boolean
default?: string
tooltip?: TypeWithI18N
placeholder?: TypeWithI18N
show_on: { variable: string, value: string }[]
options?: {
label: TypeWithI18N
value: string
show_on: { variable: string, value: string }[]
}[]
help?: TypeWithI18N | null
url?: string
}
/**
* Form schema type for tool parameters.
* This type represents the schema returned by toolParametersToFormSchemas.
@ -86,17 +115,17 @@ export const toolParametersToFormSchemas = (parameters: ToolParameter[]): ToolFo
return formSchemas
}
export const toolCredentialToFormSchemas = (parameters: ToolCredential[]) => {
export const toolCredentialToFormSchemas = (parameters: ToolCredential[]): ToolCredentialFormSchema[] => {
if (!parameters)
return []
const formSchemas = parameters.map((parameter) => {
const formSchemas = parameters.map((parameter): ToolCredentialFormSchema => {
return {
...parameter,
variable: parameter.name,
type: toType(parameter.type),
label: parameter.label,
tooltip: parameter.help,
tooltip: parameter.help ?? undefined,
show_on: [],
options: parameter.options?.map((option) => {
return {
@ -109,7 +138,7 @@ export const toolCredentialToFormSchemas = (parameters: ToolCredential[]) => {
return formSchemas
}
export const addDefaultValue = (value: Record<string, any>, formSchemas: { variable: string, type: string, default?: any }[]) => {
export const addDefaultValue = (value: Record<string, unknown>, formSchemas: { variable: string, type: string, default?: unknown }[]) => {
const newValues = { ...value }
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
@ -129,7 +158,7 @@ export const addDefaultValue = (value: Record<string, any>, formSchemas: { varia
return newValues
}
const correctInitialData = (type: string, target: any, defaultValue: any) => {
const correctInitialData = (type: string, target: FormValueInput, defaultValue: unknown): FormValueInput => {
if (type === 'text-input' || type === 'secret-input')
target.type = 'mixed'
@ -155,39 +184,39 @@ const correctInitialData = (type: string, target: any, defaultValue: any) => {
return target
}
export const generateFormValue = (value: Record<string, any>, formSchemas: { variable: string, default?: any, type: string }[], isReasoning = false) => {
const newValues = {} as any
export const generateFormValue = (value: Record<string, unknown>, formSchemas: { variable: string, default?: unknown, type: string }[], isReasoning = false) => {
const newValues: Record<string, unknown> = {}
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) {
const value = formSchema.default
newValues[formSchema.variable] = {
value: {
type: 'constant',
value: formSchema.default,
},
...(isReasoning ? { auto: 1, value: null } : {}),
const defaultVal = formSchema.default
if (isReasoning) {
newValues[formSchema.variable] = { auto: 1, value: null }
}
else {
const initialValue: FormValueInput = { type: 'constant', value: formSchema.default }
newValues[formSchema.variable] = {
value: correctInitialData(formSchema.type, initialValue, defaultVal),
}
}
if (!isReasoning)
newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, value)
}
})
return newValues
}
export const getPlainValue = (value: Record<string, any>) => {
const plainValue = { ...value }
Object.keys(plainValue).forEach((key) => {
export const getPlainValue = (value: Record<string, { value: unknown }>) => {
const plainValue: Record<string, unknown> = {}
Object.keys(value).forEach((key) => {
plainValue[key] = {
...value[key].value,
...(value[key].value as object),
}
})
return plainValue
}
export const getStructureValue = (value: Record<string, any>) => {
const newValue = { ...value } as any
Object.keys(newValue).forEach((key) => {
export const getStructureValue = (value: Record<string, unknown>): Record<string, { value: unknown }> => {
const newValue: Record<string, { value: unknown }> = {}
Object.keys(value).forEach((key) => {
newValue[key] = {
value: value[key],
}
@ -195,17 +224,17 @@ export const getStructureValue = (value: Record<string, any>) => {
return newValue
}
export const getConfiguredValue = (value: Record<string, any>, formSchemas: { variable: string, type: string, default?: any }[]) => {
const newValues = { ...value }
export const getConfiguredValue = (value: Record<string, unknown>, formSchemas: { variable: string, type: string, default?: unknown }[]) => {
const newValues: Record<string, unknown> = { ...value }
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) {
const value = formSchema.default
newValues[formSchema.variable] = {
const defaultVal = formSchema.default
const initialValue: FormValueInput = {
type: 'constant',
value: typeof formSchema.default === 'string' ? formSchema.default.replace(/\n/g, '\\n') : formSchema.default,
}
newValues[formSchema.variable] = correctInitialData(formSchema.type, newValues[formSchema.variable], value)
newValues[formSchema.variable] = correctInitialData(formSchema.type, initialValue, defaultVal)
}
})
return newValues
@ -220,24 +249,24 @@ const getVarKindType = (type: FormTypeEnum) => {
return VarKindType.mixed
}
export const generateAgentToolValue = (value: Record<string, any>, formSchemas: { variable: string, default?: any, type: string }[], isReasoning = false) => {
const newValues = {} as any
export const generateAgentToolValue = (value: Record<string, { value?: unknown, auto?: 0 | 1 }>, formSchemas: { variable: string, default?: unknown, type: string }[], isReasoning = false) => {
const newValues: Record<string, { value: FormValueInput | null, auto?: 0 | 1 }> = {}
if (!isReasoning) {
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
newValues[formSchema.variable] = {
value: {
type: 'constant',
value: itemValue.value,
value: itemValue?.value,
},
}
newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, itemValue.value)
newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value!, itemValue?.value)
})
}
else {
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if (itemValue.auto === 1) {
if (itemValue?.auto === 1) {
newValues[formSchema.variable] = {
auto: 1,
value: null,
@ -246,7 +275,7 @@ export const generateAgentToolValue = (value: Record<string, any>, formSchemas:
else {
newValues[formSchema.variable] = {
auto: 0,
value: itemValue.value || {
value: (itemValue?.value as FormValueInput) || {
type: getVarKindType(formSchema.type as FormTypeEnum),
value: null,
},

View File

@ -174,7 +174,7 @@ const useConfig = (id: string, payload: ToolNodeType) => {
draft.tool_configurations = getConfiguredValue(
tool_configurations,
toolSettingSchema,
)
) as ToolVarInputs
}
if (
!draft.tool_parameters
@ -183,7 +183,7 @@ const useConfig = (id: string, payload: ToolNodeType) => {
draft.tool_parameters = getConfiguredValue(
tool_parameters,
toolInputVarSchema,
)
) as ToolVarInputs
}
})
return inputsWithDefaultValue

View File

@ -2580,11 +2580,6 @@
"count": 8
}
},
"app/components/plugins/plugin-detail-panel/app-selector/index.tsx": {
"ts/no-explicit-any": {
"count": 5
}
},
"app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2671,26 +2666,6 @@
"count": 2
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": {
"ts/no-explicit-any": {
"count": 15
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx": {
"ts/no-explicit-any": {
"count": 24
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/tool-credentials-form.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/tool-item.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
"ts/no-explicit-any": {
"count": 5
@ -3085,11 +3060,6 @@
"count": 4
}
},
"app/components/tools/utils/to-form-schema.ts": {
"ts/no-explicit-any": {
"count": 15
}
},
"app/components/tools/workflow-tool/configure-button.tsx": {
"react-hooks/preserve-manual-memoization": {
"count": 2
@ -4919,11 +4889,6 @@
"count": 4
}
},
"service/tools.ts": {
"ts/no-explicit-any": {
"count": 2
}
},
"service/use-apps.ts": {
"ts/no-explicit-any": {
"count": 1

View File

@ -1,5 +1,6 @@
import type {
Collection,
Credential,
CustomCollectionBackend,
CustomParamSchema,
Tool,
@ -41,9 +42,9 @@ export const fetchBuiltInToolCredentialSchema = (collectionName: string) => {
}
export const fetchBuiltInToolCredential = (collectionName: string) => {
return get<ToolCredential[]>(`/workspaces/current/tool-provider/builtin/${collectionName}/credentials`)
return get<Record<string, unknown>>(`/workspaces/current/tool-provider/builtin/${collectionName}/credentials`)
}
export const updateBuiltInToolCredential = (collectionName: string, credential: Record<string, any>) => {
export const updateBuiltInToolCredential = (collectionName: string, credential: Record<string, unknown>) => {
return post(`/workspaces/current/tool-provider/builtin/${collectionName}/update`, {
body: {
credentials: credential,
@ -102,7 +103,14 @@ export const importSchemaFromURL = (url: string) => {
})
}
export const testAPIAvailable = (payload: any) => {
export const testAPIAvailable = (payload: {
provider_name: string
tool_name: string
credentials: Credential
schema_type: string
schema: string
parameters: Record<string, string>
}) => {
return post('/workspaces/current/tool-provider/api/test/pre', {
body: {
...payload,