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:
Coding On Star
2026-02-13 17:21:34 +08:00
committed by GitHub
parent 98466e2d29
commit 210710e76d
21 changed files with 2595 additions and 983 deletions

View File

@ -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

View File

@ -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}
/>
)}
</>

View File

@ -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()
})
})
})

View File

@ -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,
}
}