mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
refactor(web): extract custom hooks from complex components and add comprehensive tests (#32301)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
@ -30,19 +30,21 @@ vi.mock('@/context/app-context', () => ({
|
||||
}))
|
||||
|
||||
// Mock API services - only mock external services
|
||||
const mockFetchWorkflowToolDetailByAppID = vi.fn()
|
||||
const mockCreateWorkflowToolProvider = vi.fn()
|
||||
const mockSaveWorkflowToolProvider = vi.fn()
|
||||
vi.mock('@/service/tools', () => ({
|
||||
fetchWorkflowToolDetailByAppID: (...args: unknown[]) => mockFetchWorkflowToolDetailByAppID(...args),
|
||||
createWorkflowToolProvider: (...args: unknown[]) => mockCreateWorkflowToolProvider(...args),
|
||||
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
|
||||
}))
|
||||
|
||||
// Mock invalidate workflow tools hook
|
||||
// Mock service hooks
|
||||
const mockInvalidateAllWorkflowTools = vi.fn()
|
||||
const mockInvalidateWorkflowToolDetailByAppID = vi.fn()
|
||||
const mockUseWorkflowToolDetailByAppID = vi.fn()
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllWorkflowTools: () => mockInvalidateAllWorkflowTools,
|
||||
useInvalidateWorkflowToolDetailByAppID: () => mockInvalidateWorkflowToolDetailByAppID,
|
||||
useWorkflowToolDetailByAppID: (...args: unknown[]) => mockUseWorkflowToolDetailByAppID(...args),
|
||||
}))
|
||||
|
||||
// Mock Toast - need to verify notification calls
|
||||
@ -242,7 +244,10 @@ describe('WorkflowToolConfigureButton', () => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpenState = false
|
||||
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
||||
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(createMockWorkflowToolDetail())
|
||||
mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({
|
||||
data: enabled ? createMockWorkflowToolDetail() : undefined,
|
||||
isLoading: false,
|
||||
}))
|
||||
})
|
||||
|
||||
// Rendering Tests (REQUIRED)
|
||||
@ -307,19 +312,17 @@ describe('WorkflowToolConfigureButton', () => {
|
||||
expect(screen.getByText('Please save the workflow first')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state when published and fetching details', async () => {
|
||||
it('should render loading state when published and fetching details', () => {
|
||||
// Arrange
|
||||
mockFetchWorkflowToolDetailByAppID.mockImplementation(() => new Promise(() => { })) // Never resolves
|
||||
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: true })
|
||||
const props = createDefaultConfigureButtonProps({ published: true })
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const loadingElement = document.querySelector('.pt-2')
|
||||
expect(loadingElement).toBeInTheDocument()
|
||||
})
|
||||
const loadingElement = document.querySelector('.pt-2')
|
||||
expect(loadingElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configure and manage buttons when published', async () => {
|
||||
@ -381,76 +384,10 @@ describe('WorkflowToolConfigureButton', () => {
|
||||
// Act & Assert
|
||||
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should call handlePublish when updating workflow tool', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handlePublish = vi.fn().mockResolvedValue(undefined)
|
||||
mockSaveWorkflowToolProvider.mockResolvedValue({})
|
||||
const props = createDefaultConfigureButtonProps({ published: true, handlePublish })
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText('workflow.common.configure'))
|
||||
|
||||
// Fill required fields and save
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
||||
})
|
||||
const saveButton = screen.getByText('common.operation.save')
|
||||
await user.click(saveButton)
|
||||
|
||||
// Confirm in modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('tools.createTool.confirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(handlePublish).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// State Management Tests
|
||||
describe('State Management', () => {
|
||||
it('should fetch detail when published and mount', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultConfigureButtonProps({ published: true })
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledWith('workflow-app-123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should refetch detail when detailNeedUpdate changes to true', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultConfigureButtonProps({ published: true, detailNeedUpdate: false })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Rerender with detailNeedUpdate true
|
||||
rerender(<WorkflowToolConfigureButton {...props} detailNeedUpdate={true} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Modal behavior tests
|
||||
describe('Modal Behavior', () => {
|
||||
it('should toggle modal visibility', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
@ -513,85 +450,6 @@ describe('WorkflowToolConfigureButton', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Memoization Tests
|
||||
describe('Memoization - outdated detection', () => {
|
||||
it('should detect outdated when parameter count differs', async () => {
|
||||
// Arrange
|
||||
const detail = createMockWorkflowToolDetail()
|
||||
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
||||
const props = createDefaultConfigureButtonProps({
|
||||
published: true,
|
||||
inputs: [
|
||||
createMockInputVar({ variable: 'test_var' }),
|
||||
createMockInputVar({ variable: 'extra_var' }),
|
||||
],
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
// Assert - should show outdated warning
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should detect outdated when parameter not found', async () => {
|
||||
// Arrange
|
||||
const detail = createMockWorkflowToolDetail()
|
||||
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
||||
const props = createDefaultConfigureButtonProps({
|
||||
published: true,
|
||||
inputs: [createMockInputVar({ variable: 'different_var' })],
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should detect outdated when required property differs', async () => {
|
||||
// Arrange
|
||||
const detail = createMockWorkflowToolDetail()
|
||||
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
||||
const props = createDefaultConfigureButtonProps({
|
||||
published: true,
|
||||
inputs: [createMockInputVar({ variable: 'test_var', required: false })], // Detail has required: true
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show outdated when parameters match', async () => {
|
||||
// Arrange
|
||||
const detail = createMockWorkflowToolDetail()
|
||||
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
||||
const props = createDefaultConfigureButtonProps({
|
||||
published: true,
|
||||
inputs: [createMockInputVar({ variable: 'test_var', required: true, type: InputVarType.textInput })],
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('workflow.common.workflowAsToolTip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions Tests
|
||||
describe('User Interactions', () => {
|
||||
it('should navigate to tools page when manage button clicked', async () => {
|
||||
@ -611,174 +469,10 @@ describe('WorkflowToolConfigureButton', () => {
|
||||
// Assert
|
||||
expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow')
|
||||
})
|
||||
|
||||
it('should create workflow tool provider on first publish', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
||||
const props = createDefaultConfigureButtonProps()
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
// Open modal
|
||||
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
||||
await user.click(triggerArea!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Fill in required name field
|
||||
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
||||
await user.type(nameInput, 'my_tool')
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockCreateWorkflowToolProvider).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show success toast after creating workflow tool', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
||||
const props = createDefaultConfigureButtonProps()
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
||||
await user.click(triggerArea!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
||||
await user.type(nameInput, 'my_tool')
|
||||
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'common.api.actionSuccess',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast when create fails', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockCreateWorkflowToolProvider.mockRejectedValue(new Error('Create failed'))
|
||||
const props = createDefaultConfigureButtonProps()
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
||||
await user.click(triggerArea!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
||||
await user.type(nameInput, 'my_tool')
|
||||
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Create failed',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onRefreshData after successful create', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onRefreshData = vi.fn()
|
||||
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
||||
const props = createDefaultConfigureButtonProps({ onRefreshData })
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
||||
await user.click(triggerArea!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
||||
await user.type(nameInput, 'my_tool')
|
||||
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(onRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate all workflow tools after successful create', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
||||
const props = createDefaultConfigureButtonProps()
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
||||
await user.click(triggerArea!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
||||
await user.type(nameInput, 'my_tool')
|
||||
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle API returning undefined', async () => {
|
||||
// Arrange - API returns undefined (simulating empty response or handled error)
|
||||
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(undefined)
|
||||
const props = createDefaultConfigureButtonProps({ published: true })
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
// Assert - should not crash and wait for API call
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Component should still render without crashing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle rapid publish/unpublish state changes', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultConfigureButtonProps({ published: false })
|
||||
@ -798,35 +492,7 @@ describe('WorkflowToolConfigureButton', () => {
|
||||
})
|
||||
|
||||
// Assert - should not crash
|
||||
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle detail with empty parameters', async () => {
|
||||
// Arrange
|
||||
const detail = createMockWorkflowToolDetail()
|
||||
detail.tool.parameters = []
|
||||
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
||||
const props = createDefaultConfigureButtonProps({ published: true, inputs: [] })
|
||||
|
||||
// Act
|
||||
render(<WorkflowToolConfigureButton {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle detail with undefined output_schema', async () => {
|
||||
// Arrange
|
||||
const detail = createMockWorkflowToolDetail()
|
||||
// @ts-expect-error - testing undefined case
|
||||
detail.tool.output_schema = undefined
|
||||
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
||||
const props = createDefaultConfigureButtonProps({ published: true })
|
||||
|
||||
// Act & Assert
|
||||
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
|
||||
expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle paragraph type input conversion', async () => {
|
||||
@ -1853,7 +1519,10 @@ describe('Integration Tests', () => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpenState = false
|
||||
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
||||
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(createMockWorkflowToolDetail())
|
||||
mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({
|
||||
data: enabled ? createMockWorkflowToolDetail() : undefined,
|
||||
isLoading: false,
|
||||
}))
|
||||
})
|
||||
|
||||
// Complete workflow: open modal -> fill form -> save
|
||||
|
||||
@ -1,22 +1,16 @@
|
||||
'use client'
|
||||
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import WorkflowToolModal from '@/app/components/tools/workflow-tool'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { createWorkflowToolProvider, fetchWorkflowToolDetailByAppID, saveWorkflowToolProvider } from '@/service/tools'
|
||||
import { useInvalidateAllWorkflowTools } from '@/service/use-tools'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Divider from '../../base/divider'
|
||||
import { useConfigureButton } from './hooks/use-configure-button'
|
||||
|
||||
type Props = {
|
||||
disabled: boolean
|
||||
@ -48,153 +42,29 @@ const WorkflowToolConfigureButton = ({
|
||||
disabledReason,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [detail, setDetail] = useState<WorkflowToolProviderResponse>()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools()
|
||||
|
||||
const outdated = useMemo(() => {
|
||||
if (!detail)
|
||||
return false
|
||||
if (detail.tool.parameters.length !== inputs?.length) {
|
||||
return true
|
||||
}
|
||||
else {
|
||||
for (const item of inputs || []) {
|
||||
const param = detail.tool.parameters.find(toolParam => toolParam.name === item.variable)
|
||||
if (!param) {
|
||||
return true
|
||||
}
|
||||
else if (param.required !== item.required) {
|
||||
return true
|
||||
}
|
||||
else {
|
||||
if (item.type === 'paragraph' && param.type !== 'string')
|
||||
return true
|
||||
if (item.type === 'text-input' && param.type !== 'string')
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, [detail, inputs])
|
||||
|
||||
const payload = useMemo(() => {
|
||||
let parameters: WorkflowToolProviderParameter[] = []
|
||||
let outputParameters: WorkflowToolProviderOutputParameter[] = []
|
||||
|
||||
if (!published) {
|
||||
parameters = (inputs || []).map((item) => {
|
||||
return {
|
||||
name: item.variable,
|
||||
description: '',
|
||||
form: 'llm',
|
||||
required: item.required,
|
||||
type: item.type,
|
||||
}
|
||||
})
|
||||
outputParameters = (outputs || []).map((item) => {
|
||||
return {
|
||||
name: item.variable,
|
||||
description: '',
|
||||
type: item.value_type,
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (detail && detail.tool) {
|
||||
parameters = (inputs || []).map((item) => {
|
||||
return {
|
||||
name: item.variable,
|
||||
required: item.required,
|
||||
type: item.type === 'paragraph' ? 'string' : item.type,
|
||||
description: detail.tool.parameters.find(param => param.name === item.variable)?.llm_description || '',
|
||||
form: detail.tool.parameters.find(param => param.name === item.variable)?.form || 'llm',
|
||||
}
|
||||
})
|
||||
outputParameters = (outputs || []).map((item) => {
|
||||
const found = detail.tool.output_schema?.properties?.[item.variable]
|
||||
return {
|
||||
name: item.variable,
|
||||
description: found ? found.description : '',
|
||||
type: item.value_type,
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
icon: detail?.icon || icon,
|
||||
label: detail?.label || name,
|
||||
name: detail?.name || '',
|
||||
description: detail?.description || description,
|
||||
parameters,
|
||||
outputParameters,
|
||||
labels: detail?.tool?.labels || [],
|
||||
privacy_policy: detail?.privacy_policy || '',
|
||||
...(published
|
||||
? {
|
||||
workflow_tool_id: detail?.workflow_tool_id,
|
||||
}
|
||||
: {
|
||||
workflow_app_id: workflowAppId,
|
||||
}),
|
||||
}
|
||||
}, [detail, published, workflowAppId, icon, name, description, inputs])
|
||||
|
||||
const getDetail = useCallback(async (workflowAppId: string) => {
|
||||
setIsLoading(true)
|
||||
const res = await fetchWorkflowToolDetailByAppID(workflowAppId)
|
||||
setDetail(res)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (published)
|
||||
getDetail(workflowAppId)
|
||||
}, [getDetail, published, workflowAppId])
|
||||
|
||||
useEffect(() => {
|
||||
if (detailNeedUpdate)
|
||||
getDetail(workflowAppId)
|
||||
}, [detailNeedUpdate, getDetail, workflowAppId])
|
||||
|
||||
const createHandle = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => {
|
||||
try {
|
||||
await createWorkflowToolProvider(data)
|
||||
invalidateAllWorkflowTools()
|
||||
onRefreshData?.()
|
||||
getDetail(workflowAppId)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
setShowModal(false)
|
||||
}
|
||||
catch (e) {
|
||||
Toast.notify({ type: 'error', message: (e as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{
|
||||
workflow_app_id: string
|
||||
workflow_tool_id: string
|
||||
}>) => {
|
||||
try {
|
||||
await handlePublish()
|
||||
await saveWorkflowToolProvider(data)
|
||||
onRefreshData?.()
|
||||
invalidateAllWorkflowTools()
|
||||
getDetail(workflowAppId)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
setShowModal(false)
|
||||
}
|
||||
catch (e) {
|
||||
Toast.notify({ type: 'error', message: (e as Error).message })
|
||||
}
|
||||
}
|
||||
const {
|
||||
showModal,
|
||||
isLoading,
|
||||
outdated,
|
||||
payload,
|
||||
isCurrentWorkspaceManager,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
navigateToTools,
|
||||
} = useConfigureButton({
|
||||
published,
|
||||
detailNeedUpdate,
|
||||
workflowAppId,
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
inputs,
|
||||
outputs,
|
||||
handlePublish,
|
||||
onRefreshData,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -210,17 +80,17 @@ const WorkflowToolConfigureButton = ({
|
||||
? (
|
||||
<div
|
||||
className="flex items-center justify-start gap-2 p-2 pl-2.5"
|
||||
onClick={() => !disabled && !published && setShowModal(true)}
|
||||
onClick={() => !disabled && !published && openModal()}
|
||||
>
|
||||
<RiHammerLine className={cn('relative h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
|
||||
<div
|
||||
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
|
||||
className={cn('system-sm-medium shrink grow basis-0 truncate text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')}
|
||||
className={cn('shrink grow basis-0 truncate text-text-secondary system-sm-medium', !disabled && !published && 'group-hover:text-text-accent')}
|
||||
>
|
||||
{t('common.workflowAsTool', { ns: 'workflow' })}
|
||||
</div>
|
||||
{!published && (
|
||||
<span className="system-2xs-medium-uppercase shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 text-text-tertiary">
|
||||
<span className="shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 text-text-tertiary system-2xs-medium-uppercase">
|
||||
{t('common.configureRequired', { ns: 'workflow' })}
|
||||
</span>
|
||||
)}
|
||||
@ -233,7 +103,7 @@ const WorkflowToolConfigureButton = ({
|
||||
<RiHammerLine className="h-4 w-4 text-text-tertiary" />
|
||||
<div
|
||||
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
|
||||
className="system-sm-medium shrink grow basis-0 truncate text-text-tertiary"
|
||||
className="shrink grow basis-0 truncate text-text-tertiary system-sm-medium"
|
||||
>
|
||||
{t('common.workflowAsTool', { ns: 'workflow' })}
|
||||
</div>
|
||||
@ -250,7 +120,7 @@ const WorkflowToolConfigureButton = ({
|
||||
<Button
|
||||
size="small"
|
||||
className="w-[140px]"
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={openModal}
|
||||
disabled={!isCurrentWorkspaceManager || disabled}
|
||||
>
|
||||
{t('common.configure', { ns: 'workflow' })}
|
||||
@ -259,7 +129,7 @@ const WorkflowToolConfigureButton = ({
|
||||
<Button
|
||||
size="small"
|
||||
className="w-[140px]"
|
||||
onClick={() => router.push('/tools?category=workflow')}
|
||||
onClick={navigateToTools}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('common.manageInTools', { ns: 'workflow' })}
|
||||
@ -280,9 +150,9 @@ const WorkflowToolConfigureButton = ({
|
||||
<WorkflowToolModal
|
||||
isAdd={!published}
|
||||
payload={payload}
|
||||
onHide={() => setShowModal(false)}
|
||||
onCreate={createHandle}
|
||||
onSave={updateWorkflowToolProvider}
|
||||
onHide={closeModal}
|
||||
onCreate={handleCreate}
|
||||
onSave={handleUpdate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -0,0 +1,541 @@
|
||||
import type { WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { isParametersOutdated, useConfigureButton } from '../use-configure-button'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
const mockIsCurrentWorkspaceManager = vi.fn(() => true)
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockCreateWorkflowToolProvider = vi.fn()
|
||||
const mockSaveWorkflowToolProvider = vi.fn()
|
||||
vi.mock('@/service/tools', () => ({
|
||||
createWorkflowToolProvider: (...args: unknown[]) => mockCreateWorkflowToolProvider(...args),
|
||||
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
|
||||
}))
|
||||
|
||||
const mockInvalidateAllWorkflowTools = vi.fn()
|
||||
const mockInvalidateWorkflowToolDetailByAppID = vi.fn()
|
||||
const mockUseWorkflowToolDetailByAppID = vi.fn()
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllWorkflowTools: () => mockInvalidateAllWorkflowTools,
|
||||
useInvalidateWorkflowToolDetailByAppID: () => mockInvalidateWorkflowToolDetailByAppID,
|
||||
useWorkflowToolDetailByAppID: (...args: unknown[]) => mockUseWorkflowToolDetailByAppID(...args),
|
||||
}))
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (options: { type: string, message: string }) => mockToastNotify(options),
|
||||
},
|
||||
}))
|
||||
|
||||
const createMockEmoji = () => ({ content: '🔧', background: '#ffffff' })
|
||||
|
||||
const createMockInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
variable: 'test_var',
|
||||
label: 'Test Variable',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
max_length: 100,
|
||||
options: [],
|
||||
...overrides,
|
||||
} as InputVar)
|
||||
|
||||
const createMockVariable = (overrides: Partial<Variable> = {}): Variable => ({
|
||||
variable: 'output_var',
|
||||
value_type: 'string',
|
||||
...overrides,
|
||||
} as Variable)
|
||||
|
||||
const createMockDetail = (overrides: Partial<WorkflowToolProviderResponse> = {}): WorkflowToolProviderResponse => ({
|
||||
workflow_app_id: 'app-123',
|
||||
workflow_tool_id: 'tool-456',
|
||||
label: 'Test Tool',
|
||||
name: 'test_tool',
|
||||
icon: createMockEmoji(),
|
||||
description: 'A test workflow tool',
|
||||
synced: true,
|
||||
tool: {
|
||||
author: 'test-author',
|
||||
name: 'test_tool',
|
||||
label: { en_US: 'Test Tool', zh_Hans: '测试工具' },
|
||||
description: { en_US: 'Test description', zh_Hans: '测试描述' },
|
||||
labels: ['label1'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'test_var',
|
||||
label: { en_US: 'Test Variable', zh_Hans: '测试变量' },
|
||||
human_description: { en_US: 'A test variable', zh_Hans: '测试变量' },
|
||||
type: 'string',
|
||||
form: 'llm',
|
||||
llm_description: 'Test variable description',
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
output_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
output_var: { type: 'string', description: 'Output description' },
|
||||
},
|
||||
},
|
||||
},
|
||||
privacy_policy: 'https://example.com/privacy',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createDefaultOptions = (overrides = {}) => ({
|
||||
published: false,
|
||||
detailNeedUpdate: false,
|
||||
workflowAppId: 'app-123',
|
||||
icon: createMockEmoji(),
|
||||
name: 'Test Workflow',
|
||||
description: 'Test workflow description',
|
||||
inputs: [createMockInputVar()],
|
||||
outputs: [createMockVariable()],
|
||||
handlePublish: vi.fn().mockResolvedValue(undefined),
|
||||
onRefreshData: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockRequest = (extra: Record<string, string> = {}): WorkflowToolProviderRequest & Record<string, unknown> => ({
|
||||
name: 'test_tool',
|
||||
description: 'desc',
|
||||
icon: createMockEmoji(),
|
||||
label: 'Test Tool',
|
||||
parameters: [{ name: 'test_var', description: '', form: 'llm' }],
|
||||
labels: [],
|
||||
privacy_policy: '',
|
||||
...extra,
|
||||
})
|
||||
|
||||
describe('isParametersOutdated', () => {
|
||||
it('should return false when detail is undefined', () => {
|
||||
expect(isParametersOutdated(undefined, [createMockInputVar()])).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when parameter count differs', () => {
|
||||
const detail = createMockDetail()
|
||||
const inputs = [
|
||||
createMockInputVar({ variable: 'test_var' }),
|
||||
createMockInputVar({ variable: 'extra_var' }),
|
||||
]
|
||||
expect(isParametersOutdated(detail, inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when parameter is not found in detail', () => {
|
||||
const detail = createMockDetail()
|
||||
const inputs = [createMockInputVar({ variable: 'unknown_var' })]
|
||||
expect(isParametersOutdated(detail, inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when required property differs', () => {
|
||||
const detail = createMockDetail()
|
||||
const inputs = [createMockInputVar({ variable: 'test_var', required: false })]
|
||||
expect(isParametersOutdated(detail, inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when paragraph type does not match string', () => {
|
||||
const detail = createMockDetail()
|
||||
detail.tool.parameters[0].type = 'number'
|
||||
const inputs = [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })]
|
||||
expect(isParametersOutdated(detail, inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when text-input type does not match string', () => {
|
||||
const detail = createMockDetail()
|
||||
detail.tool.parameters[0].type = 'number'
|
||||
const inputs = [createMockInputVar({ variable: 'test_var', type: InputVarType.textInput })]
|
||||
expect(isParametersOutdated(detail, inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when paragraph type matches string', () => {
|
||||
const detail = createMockDetail()
|
||||
const inputs = [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })]
|
||||
expect(isParametersOutdated(detail, inputs)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when text-input type matches string', () => {
|
||||
const detail = createMockDetail()
|
||||
const inputs = [createMockInputVar({ variable: 'test_var', type: InputVarType.textInput })]
|
||||
expect(isParametersOutdated(detail, inputs)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when all parameters match', () => {
|
||||
const detail = createMockDetail()
|
||||
const inputs = [createMockInputVar({ variable: 'test_var', required: true })]
|
||||
expect(isParametersOutdated(detail, inputs)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle undefined inputs with empty detail parameters', () => {
|
||||
const detail = createMockDetail()
|
||||
detail.tool.parameters = []
|
||||
expect(isParametersOutdated(detail, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when inputs undefined but detail has parameters', () => {
|
||||
const detail = createMockDetail()
|
||||
expect(isParametersOutdated(detail, undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle empty inputs and empty detail parameters', () => {
|
||||
const detail = createMockDetail()
|
||||
detail.tool.parameters = []
|
||||
expect(isParametersOutdated(detail, [])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useConfigureButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
||||
mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({
|
||||
data: enabled ? createMockDetail() : undefined,
|
||||
isLoading: false,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should return showModal as false by default', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should forward isCurrentWorkspaceManager from context', () => {
|
||||
mockIsCurrentWorkspaceManager.mockReturnValue(false)
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
expect(result.current.isCurrentWorkspaceManager).toBe(false)
|
||||
})
|
||||
|
||||
it('should forward isLoading from query hook', () => {
|
||||
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: true })
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('should call query hook with enabled=true when published', () => {
|
||||
renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
|
||||
expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', true)
|
||||
})
|
||||
|
||||
it('should call query hook with enabled=false when not published', () => {
|
||||
renderHook(() => useConfigureButton(createDefaultOptions({ published: false })))
|
||||
expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false)
|
||||
})
|
||||
})
|
||||
|
||||
// Computed values
|
||||
describe('Computed - outdated', () => {
|
||||
it('should be false when not published (no detail)', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
expect(result.current.outdated).toBe(false)
|
||||
})
|
||||
|
||||
it('should be true when parameters differ', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
|
||||
published: true,
|
||||
inputs: [
|
||||
createMockInputVar({ variable: 'test_var' }),
|
||||
createMockInputVar({ variable: 'extra_var' }),
|
||||
],
|
||||
})))
|
||||
expect(result.current.outdated).toBe(true)
|
||||
})
|
||||
|
||||
it('should be false when parameters match', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
|
||||
published: true,
|
||||
inputs: [createMockInputVar({ variable: 'test_var', required: true })],
|
||||
})))
|
||||
expect(result.current.outdated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Computed - payload', () => {
|
||||
it('should use prop values when not published', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
|
||||
expect(result.current.payload).toMatchObject({
|
||||
icon: createMockEmoji(),
|
||||
label: 'Test Workflow',
|
||||
name: '',
|
||||
description: 'Test workflow description',
|
||||
workflow_app_id: 'app-123',
|
||||
})
|
||||
expect(result.current.payload.parameters).toHaveLength(1)
|
||||
expect(result.current.payload.parameters[0]).toMatchObject({
|
||||
name: 'test_var',
|
||||
form: 'llm',
|
||||
description: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should use detail values when published with detail', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
|
||||
|
||||
expect(result.current.payload).toMatchObject({
|
||||
icon: createMockEmoji(),
|
||||
label: 'Test Tool',
|
||||
name: 'test_tool',
|
||||
description: 'A test workflow tool',
|
||||
workflow_tool_id: 'tool-456',
|
||||
privacy_policy: 'https://example.com/privacy',
|
||||
labels: ['label1'],
|
||||
})
|
||||
expect(result.current.payload.parameters[0]).toMatchObject({
|
||||
name: 'test_var',
|
||||
description: 'Test variable description',
|
||||
form: 'llm',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty parameters when published without detail', () => {
|
||||
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: false })
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
|
||||
|
||||
expect(result.current.payload.parameters).toHaveLength(0)
|
||||
expect(result.current.payload.outputParameters).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should build output parameters from detail output_schema', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
|
||||
|
||||
expect(result.current.payload.outputParameters).toHaveLength(1)
|
||||
expect(result.current.payload.outputParameters[0]).toMatchObject({
|
||||
name: 'output_var',
|
||||
description: 'Output description',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle undefined output_schema in detail', () => {
|
||||
const detail = createMockDetail()
|
||||
// @ts-expect-error - testing undefined case
|
||||
detail.tool.output_schema = undefined
|
||||
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: detail, isLoading: false })
|
||||
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
|
||||
|
||||
expect(result.current.payload.outputParameters[0]).toMatchObject({
|
||||
name: 'output_var',
|
||||
description: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert paragraph type to string in existing parameters', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
|
||||
published: true,
|
||||
inputs: [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })],
|
||||
})))
|
||||
|
||||
expect(result.current.payload.parameters[0].type).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
// Modal controls
|
||||
describe('Modal Controls', () => {
|
||||
it('should open modal via openModal', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
act(() => {
|
||||
result.current.openModal()
|
||||
})
|
||||
expect(result.current.showModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should close modal via closeModal', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
act(() => {
|
||||
result.current.openModal()
|
||||
})
|
||||
act(() => {
|
||||
result.current.closeModal()
|
||||
})
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should navigate to tools page', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
act(() => {
|
||||
result.current.navigateToTools()
|
||||
})
|
||||
expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow')
|
||||
})
|
||||
})
|
||||
|
||||
// Mutation handlers
|
||||
describe('handleCreate', () => {
|
||||
it('should create provider, invalidate caches, refresh, and close modal', async () => {
|
||||
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
||||
const onRefreshData = vi.fn()
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData })))
|
||||
|
||||
act(() => {
|
||||
result.current.openModal()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string })
|
||||
})
|
||||
|
||||
expect(mockCreateWorkflowToolProvider).toHaveBeenCalled()
|
||||
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
|
||||
expect(onRefreshData).toHaveBeenCalled()
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) })
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
mockCreateWorkflowToolProvider.mockRejectedValue(new Error('Create failed'))
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string })
|
||||
})
|
||||
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Create failed' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUpdate', () => {
|
||||
it('should publish, save, invalidate caches, and close modal', async () => {
|
||||
mockSaveWorkflowToolProvider.mockResolvedValue({})
|
||||
const handlePublish = vi.fn().mockResolvedValue(undefined)
|
||||
const onRefreshData = vi.fn()
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
|
||||
published: true,
|
||||
handlePublish,
|
||||
onRefreshData,
|
||||
})))
|
||||
|
||||
act(() => {
|
||||
result.current.openModal()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate(createMockRequest({ workflow_tool_id: 'tool-456' }) as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>)
|
||||
})
|
||||
|
||||
expect(handlePublish).toHaveBeenCalled()
|
||||
expect(mockSaveWorkflowToolProvider).toHaveBeenCalled()
|
||||
expect(onRefreshData).toHaveBeenCalled()
|
||||
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) })
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should show error toast when publish fails', async () => {
|
||||
const handlePublish = vi.fn().mockRejectedValue(new Error('Publish failed'))
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
|
||||
published: true,
|
||||
handlePublish,
|
||||
})))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate(createMockRequest() as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>)
|
||||
})
|
||||
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Publish failed' })
|
||||
})
|
||||
|
||||
it('should show error toast when save fails', async () => {
|
||||
mockSaveWorkflowToolProvider.mockRejectedValue(new Error('Save failed'))
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate(createMockRequest() as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>)
|
||||
})
|
||||
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Save failed' })
|
||||
})
|
||||
})
|
||||
|
||||
// Effects
|
||||
describe('Effects', () => {
|
||||
it('should invalidate detail when detailNeedUpdate becomes true', () => {
|
||||
const options = createDefaultOptions({ published: true, detailNeedUpdate: false })
|
||||
const { rerender } = renderHook(
|
||||
(props: ReturnType<typeof createDefaultOptions>) => useConfigureButton(props),
|
||||
{ initialProps: options },
|
||||
)
|
||||
|
||||
rerender({ ...options, detailNeedUpdate: true })
|
||||
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
|
||||
})
|
||||
|
||||
it('should not invalidate when detailNeedUpdate stays false', () => {
|
||||
const options = createDefaultOptions({ published: true, detailNeedUpdate: false })
|
||||
const { rerender } = renderHook(
|
||||
(props: ReturnType<typeof createDefaultOptions>) => useConfigureButton(props),
|
||||
{ initialProps: options },
|
||||
)
|
||||
|
||||
rerender({ ...options })
|
||||
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined detail from query gracefully', () => {
|
||||
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: false })
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
|
||||
|
||||
expect(result.current.outdated).toBe(false)
|
||||
expect(result.current.payload.parameters).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle detail with empty parameters', () => {
|
||||
const detail = createMockDetail()
|
||||
detail.tool.parameters = []
|
||||
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: detail, isLoading: false })
|
||||
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
|
||||
published: true,
|
||||
inputs: [],
|
||||
})))
|
||||
|
||||
expect(result.current.outdated).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle undefined inputs and outputs', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
|
||||
inputs: undefined,
|
||||
outputs: undefined,
|
||||
})))
|
||||
|
||||
expect(result.current.payload.parameters).toHaveLength(0)
|
||||
expect(result.current.payload.outputParameters).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle missing onRefreshData callback in create', async () => {
|
||||
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
|
||||
onRefreshData: undefined,
|
||||
})))
|
||||
|
||||
// Should not throw
|
||||
await act(async () => {
|
||||
await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string })
|
||||
})
|
||||
|
||||
expect(mockCreateWorkflowToolProvider).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,235 @@
|
||||
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools'
|
||||
import { useInvalidateAllWorkflowTools, useInvalidateWorkflowToolDetailByAppID, useWorkflowToolDetailByAppID } from '@/service/use-tools'
|
||||
|
||||
// region Pure helpers
|
||||
|
||||
/**
|
||||
* Check if workflow tool parameters are outdated compared to current inputs.
|
||||
* Uses flat early-return style to reduce cyclomatic complexity.
|
||||
*/
|
||||
export function isParametersOutdated(
|
||||
detail: WorkflowToolProviderResponse | undefined,
|
||||
inputs: InputVar[] | undefined,
|
||||
): boolean {
|
||||
if (!detail)
|
||||
return false
|
||||
if (detail.tool.parameters.length !== (inputs?.length ?? 0))
|
||||
return true
|
||||
|
||||
for (const item of inputs || []) {
|
||||
const param = detail.tool.parameters.find(p => p.name === item.variable)
|
||||
if (!param)
|
||||
return true
|
||||
if (param.required !== item.required)
|
||||
return true
|
||||
const needsStringType = item.type === 'paragraph' || item.type === 'text-input'
|
||||
if (needsStringType && param.type !== 'string')
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function buildNewParameters(inputs?: InputVar[]): WorkflowToolProviderParameter[] {
|
||||
return (inputs || []).map(item => ({
|
||||
name: item.variable,
|
||||
description: '',
|
||||
form: 'llm',
|
||||
required: item.required,
|
||||
type: item.type,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildExistingParameters(
|
||||
inputs: InputVar[] | undefined,
|
||||
detail: WorkflowToolProviderResponse,
|
||||
): WorkflowToolProviderParameter[] {
|
||||
return (inputs || []).map((item) => {
|
||||
const matched = detail.tool.parameters.find(p => p.name === item.variable)
|
||||
return {
|
||||
name: item.variable,
|
||||
required: item.required,
|
||||
type: item.type === 'paragraph' ? 'string' : item.type,
|
||||
description: matched?.llm_description || '',
|
||||
form: matched?.form || 'llm',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildNewOutputParameters(outputs?: Variable[]): WorkflowToolProviderOutputParameter[] {
|
||||
return (outputs || []).map(item => ({
|
||||
name: item.variable,
|
||||
description: '',
|
||||
type: item.value_type,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildExistingOutputParameters(
|
||||
outputs: Variable[] | undefined,
|
||||
detail: WorkflowToolProviderResponse,
|
||||
): WorkflowToolProviderOutputParameter[] {
|
||||
return (outputs || []).map((item) => {
|
||||
const found = detail.tool.output_schema?.properties?.[item.variable]
|
||||
return {
|
||||
name: item.variable,
|
||||
description: found ? found.description : '',
|
||||
type: item.value_type,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
type UseConfigureButtonOptions = {
|
||||
published: boolean
|
||||
detailNeedUpdate: boolean
|
||||
workflowAppId: string
|
||||
icon: Emoji
|
||||
name: string
|
||||
description: string
|
||||
inputs?: InputVar[]
|
||||
outputs?: Variable[]
|
||||
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
|
||||
onRefreshData?: () => void
|
||||
}
|
||||
|
||||
export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
const {
|
||||
published,
|
||||
detailNeedUpdate,
|
||||
workflowAppId,
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
inputs,
|
||||
outputs,
|
||||
handlePublish,
|
||||
onRefreshData,
|
||||
} = options
|
||||
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
// Data fetching via React Query
|
||||
const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, published)
|
||||
|
||||
// Invalidation functions (store in ref for stable effect dependency)
|
||||
const invalidateDetail = useInvalidateWorkflowToolDetailByAppID()
|
||||
const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools()
|
||||
|
||||
const invalidateDetailRef = useRef(invalidateDetail)
|
||||
invalidateDetailRef.current = invalidateDetail
|
||||
|
||||
// Refetch when detailNeedUpdate becomes true
|
||||
useEffect(() => {
|
||||
if (detailNeedUpdate)
|
||||
invalidateDetailRef.current(workflowAppId)
|
||||
}, [detailNeedUpdate, workflowAppId])
|
||||
|
||||
// Computed values
|
||||
const outdated = useMemo(
|
||||
() => isParametersOutdated(detail, inputs),
|
||||
[detail, inputs],
|
||||
)
|
||||
|
||||
const payload = useMemo(() => {
|
||||
const hasPublishedDetail = published && detail?.tool
|
||||
|
||||
const parameters = !published
|
||||
? buildNewParameters(inputs)
|
||||
: hasPublishedDetail
|
||||
? buildExistingParameters(inputs, detail)
|
||||
: []
|
||||
|
||||
const outputParameters = !published
|
||||
? buildNewOutputParameters(outputs)
|
||||
: hasPublishedDetail
|
||||
? buildExistingOutputParameters(outputs, detail)
|
||||
: []
|
||||
|
||||
return {
|
||||
icon: detail?.icon || icon,
|
||||
label: detail?.label || name,
|
||||
name: detail?.name || '',
|
||||
description: detail?.description || description,
|
||||
parameters,
|
||||
outputParameters,
|
||||
labels: detail?.tool?.labels || [],
|
||||
privacy_policy: detail?.privacy_policy || '',
|
||||
...(published
|
||||
? { workflow_tool_id: detail?.workflow_tool_id }
|
||||
: { workflow_app_id: workflowAppId }),
|
||||
}
|
||||
}, [detail, published, workflowAppId, icon, name, description, inputs, outputs])
|
||||
|
||||
// Modal controls (stable callbacks)
|
||||
const openModal = useCallback(() => setShowModal(true), [])
|
||||
const closeModal = useCallback(() => setShowModal(false), [])
|
||||
const navigateToTools = useCallback(
|
||||
() => router.push('/tools?category=workflow'),
|
||||
[router],
|
||||
)
|
||||
|
||||
// Mutation handlers (not memoized — only used in conditionally-rendered modal)
|
||||
const handleCreate = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => {
|
||||
try {
|
||||
await createWorkflowToolProvider(data)
|
||||
invalidateAllWorkflowTools()
|
||||
onRefreshData?.()
|
||||
invalidateDetail(workflowAppId)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
setShowModal(false)
|
||||
}
|
||||
catch (e) {
|
||||
Toast.notify({ type: 'error', message: (e as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (data: WorkflowToolProviderRequest & Partial<{
|
||||
workflow_app_id: string
|
||||
workflow_tool_id: string
|
||||
}>) => {
|
||||
try {
|
||||
await handlePublish()
|
||||
await saveWorkflowToolProvider(data)
|
||||
onRefreshData?.()
|
||||
invalidateAllWorkflowTools()
|
||||
invalidateDetail(workflowAppId)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
setShowModal(false)
|
||||
}
|
||||
catch (e) {
|
||||
Toast.notify({ type: 'error', message: (e as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showModal,
|
||||
isLoading,
|
||||
outdated,
|
||||
payload,
|
||||
isCurrentWorkspaceManager,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
navigateToTools,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user