Compare commits

..

4 Commits

Author SHA1 Message Date
f72aaf9ff2 refactor(workflow-tool): enhance testing and modal integration
- Introduced a custom QueryClientProvider for improved test isolation in WorkflowToolConfigureButton tests.
- Updated tests to utilize the new renderWithQueryClient function for consistent query handling.
- Refactored modal state management to ensure proper updates and handling of external changes.
- Improved type definitions for better clarity and maintainability.
- Added comprehensive tests for edge cases and user interactions in the WorkflowToolConfigureButton component.
2026-01-26 16:08:57 +08:00
7f8aaa33f7 suppression 2026-01-26 15:25:51 +08:00
2f52e62835 Merge branch 'main' into refactor/tools-workflow 2026-01-26 15:24:48 +08:00
0b3bf03818 refactor(workflow-tool): update types and improve modal handling
- Removed explicit 'any' types in favor of 'unknown' for better type safety.
- Refactored the WorkflowToolConfigureButton component to utilize a custom hook for managing modal state and logic.
- Introduced new components for input and output tables to streamline the workflow tool configuration process.
- Enhanced the form handling logic with a dedicated hook for managing form state and validation.
- Cleaned up unused imports and improved overall code organization.
2026-01-26 15:19:38 +08:00
16 changed files with 4097 additions and 591 deletions

View File

@ -131,7 +131,7 @@ class BillingService:
headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key}
url = f"{cls.base_url}{endpoint}"
response = httpx.request(method, url, json=json, params=params, headers=headers, follow_redirects=True)
response = httpx.request(method, url, json=json, params=params, headers=headers)
if method == "GET" and response.status_code != httpx.codes.OK:
raise ValueError("Unable to retrieve billing information. Please try again later or contact support.")
if method == "PUT":
@ -143,9 +143,6 @@ class BillingService:
raise ValueError("Invalid arguments.")
if method == "POST" and response.status_code != httpx.codes.OK:
raise ValueError(f"Unable to send request to {url}. Please try again later or contact support.")
if method == "DELETE" and response.status_code != httpx.codes.OK:
logger.error("billing_service: DELETE response: %s %s", response.status_code, response.text)
raise ValueError(f"Unable to process delete request {url}. Please try again later or contact support.")
return response.json()
@staticmethod
@ -168,7 +165,7 @@ class BillingService:
def delete_account(cls, account_id: str):
"""Delete account."""
params = {"account_id": account_id}
return cls._send_request("DELETE", "/account", params=params)
return cls._send_request("DELETE", "/account/", params=params)
@classmethod
def is_email_in_freeze(cls, email: str) -> bool:

View File

@ -171,26 +171,22 @@ class TestBillingServiceSendRequest:
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
)
def test_delete_request_non_200_with_valid_json(self, mock_httpx_request, mock_billing_config, status_code):
"""Test DELETE request with non-200 status code raises ValueError.
"""Test DELETE request with non-200 status code but valid JSON response.
DELETE now checks status code and raises ValueError for non-200 responses.
DELETE doesn't check status code, so it returns the error JSON.
"""
# Arrange
error_response = {"detail": "Error message"}
mock_response = MagicMock()
mock_response.status_code = status_code
mock_response.text = "Error message"
mock_response.json.return_value = error_response
mock_httpx_request.return_value = mock_response
# Act & Assert
with patch("services.billing_service.logger") as mock_logger:
with pytest.raises(ValueError) as exc_info:
BillingService._send_request("DELETE", "/test", json={"key": "value"})
assert "Unable to process delete request" in str(exc_info.value)
# Verify error logging
mock_logger.error.assert_called_once()
assert "DELETE response" in str(mock_logger.error.call_args)
# Act
result = BillingService._send_request("DELETE", "/test", json={"key": "value"})
# Assert
assert result == error_response
@pytest.mark.parametrize(
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
@ -214,9 +210,9 @@ class TestBillingServiceSendRequest:
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
)
def test_delete_request_non_200_with_invalid_json(self, mock_httpx_request, mock_billing_config, status_code):
"""Test DELETE request with non-200 status code raises ValueError before JSON parsing.
"""Test DELETE request with non-200 status code and invalid JSON response raises exception.
DELETE now checks status code before calling response.json(), so ValueError is raised
DELETE doesn't check status code, so it calls response.json() which raises JSONDecodeError
when the response cannot be parsed as JSON (e.g., empty response).
"""
# Arrange
@ -227,13 +223,8 @@ class TestBillingServiceSendRequest:
mock_httpx_request.return_value = mock_response
# Act & Assert
with patch("services.billing_service.logger") as mock_logger:
with pytest.raises(ValueError) as exc_info:
BillingService._send_request("DELETE", "/test", json={"key": "value"})
assert "Unable to process delete request" in str(exc_info.value)
# Verify error logging
mock_logger.error.assert_called_once()
assert "DELETE response" in str(mock_logger.error.call_args)
with pytest.raises(json.JSONDecodeError):
BillingService._send_request("DELETE", "/test", json={"key": "value"})
def test_retry_on_request_error(self, mock_httpx_request, mock_billing_config):
"""Test that _send_request retries on httpx.RequestError."""
@ -798,7 +789,7 @@ class TestBillingServiceAccountManagement:
# Assert
assert result == expected_response
mock_send_request.assert_called_once_with("DELETE", "/account", params={"account_id": account_id})
mock_send_request.assert_called_once_with("DELETE", "/account/", params={"account_id": account_id})
def test_is_email_in_freeze_true(self, mock_send_request):
"""Test checking if email is frozen (returns True)."""

View File

@ -25,10 +25,9 @@ export const useModelFormSchemas = (
model_credential_schema,
} = provider
const formSchemas = useMemo(() => {
const schemas = providerFormSchemaPredefined
? provider_credential_schema?.credential_form_schemas
: model_credential_schema?.credential_form_schemas
return Array.isArray(schemas) ? schemas : []
return providerFormSchemaPredefined
? provider_credential_schema.credential_form_schemas
: model_credential_schema.credential_form_schemas
}, [
providerFormSchemaPredefined,
provider_credential_schema?.credential_form_schemas,

View File

@ -1,6 +1,5 @@
'use client'
import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types'
import type { WorkflowToolModalPayload } from '@/app/components/tools/workflow-tool'
import {
RiCloseLine,
} from '@remixicon/react'
@ -411,9 +410,9 @@ const ProviderDetail = ({
onRemove={onClickCustomToolDelete}
/>
)}
{isShowEditWorkflowToolModal && (
{isShowEditWorkflowToolModal && customCollection && (
<WorkflowToolModal
payload={customCollection as unknown as WorkflowToolModalPayload}
payload={customCollection as WorkflowToolProviderResponse & { parameters: { name: string, description: string, form: string, required?: boolean, type?: string }[], labels: string[] }}
onHide={() => setIsShowEditWorkflowToolModal(false)}
onRemove={onClickWorkflowToolDelete}
onSave={updateWorkflowToolProvider}

View File

@ -52,7 +52,7 @@ export type Collection = {
icon_dark?: string | Emoji
label: TypeWithI18N
type: CollectionType | string
team_credentials: Record<string, any>
team_credentials: Record<string, unknown>
is_team_authorization: boolean
allow_delete: boolean
labels: string[]
@ -124,6 +124,7 @@ export type Event = {
description: TypeWithI18N
parameters: TriggerParameter[]
labels: string[]
// eslint-disable-next-line ts/no-explicit-any
output_schema: Record<string, any>
}
@ -131,9 +132,10 @@ export type Tool = {
name: string
author: string
label: TypeWithI18N
description: any
description: TypeWithI18N
parameters: ToolParameter[]
labels: string[]
// eslint-disable-next-line ts/no-explicit-any
output_schema: Record<string, any>
}
@ -215,6 +217,7 @@ export type WorkflowToolProviderOutputSchema = {
export type WorkflowToolProviderRequest = {
name: string
label: string
icon: Emoji
description: string
parameters: WorkflowToolProviderParameter[]

View File

@ -0,0 +1,105 @@
'use client'
import type { FC } from 'react'
import type { WorkflowToolProviderParameter } from '@/app/components/tools/types'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import MethodSelector from '../method-selector'
type ToolInputTableProps = {
parameters: WorkflowToolProviderParameter[]
onParameterChange: (key: 'description' | 'form', value: string, index: number) => void
}
type ParameterRowProps = {
item: WorkflowToolProviderParameter
index: number
onParameterChange: (key: 'description' | 'form', value: string, index: number) => void
}
const ParameterRow: FC<ParameterRowProps> = ({ item, index, onParameterChange }) => {
const { t } = useTranslation()
const isImageParameter = item.name === '__image'
return (
<tr className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex">
<span className="truncate font-medium text-text-primary">{item.name}</span>
{item.required && (
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">
{t('createTool.toolInput.required', { ns: 'tools' })}
</span>
)}
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td>
{isImageParameter
? (
<div className={cn(
'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2',
)}
>
<div className="grow truncate text-[13px] leading-[18px] text-text-secondary">
{t('createTool.toolInput.methodParameter', { ns: 'tools' })}
</div>
</div>
)
: (
<MethodSelector
value={item.form}
onChange={value => onParameterChange('form', value, index)}
/>
)}
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<input
type="text"
className="w-full appearance-none bg-transparent text-[13px] font-normal leading-[18px] text-text-secondary caret-primary-600 outline-none placeholder:text-text-quaternary"
placeholder={t('createTool.toolInput.descriptionPlaceholder', { ns: 'tools' })!}
value={item.description}
onChange={e => onParameterChange('description', e.target.value, index)}
/>
</td>
</tr>
)
}
const ToolInputTable: FC<ToolInputTableProps> = ({ parameters, onParameterChange }) => {
const { t } = useTranslation()
return (
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs font-normal leading-[18px] text-text-secondary">
<thead className="uppercase text-text-tertiary">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">
{t('createTool.toolInput.name', { ns: 'tools' })}
</th>
<th className="w-[102px] p-2 pl-3 font-medium">
{t('createTool.toolInput.method', { ns: 'tools' })}
</th>
<th className="p-2 pl-3 font-medium">
{t('createTool.toolInput.description', { ns: 'tools' })}
</th>
</tr>
</thead>
<tbody>
{parameters.map((item, index) => (
<ParameterRow
key={item.name}
item={item}
index={index}
onParameterChange={onParameterChange}
/>
))}
</tbody>
</table>
</div>
)
}
export default React.memo(ToolInputTable)

View File

@ -0,0 +1,88 @@
'use client'
import type { FC } from 'react'
import type { WorkflowToolProviderOutputParameter } from '@/app/components/tools/types'
import { RiErrorWarningLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
type ToolOutputTableProps = {
parameters: WorkflowToolProviderOutputParameter[]
isReserved: (name: string) => boolean
}
type OutputRowProps = {
item: WorkflowToolProviderOutputParameter
isReserved: (name: string) => boolean
}
const OutputRow: FC<OutputRowProps> = ({ item, isReserved }) => {
const { t } = useTranslation()
const showDuplicateWarning = !item.reserved && isReserved(item.name)
return (
<tr className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex items-center">
<span className="truncate font-medium text-text-primary">{item.name}</span>
{item.reserved && (
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">
{t('createTool.toolOutput.reserved', { ns: 'tools' })}
</span>
)}
{showDuplicateWarning && (
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })}
</div>
)}
>
<RiErrorWarningLine className="h-3 w-3 text-text-warning-secondary" />
</Tooltip>
)}
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<span className="text-[13px] font-normal leading-[18px] text-text-secondary">
{item.description}
</span>
</td>
</tr>
)
}
const ToolOutputTable: FC<ToolOutputTableProps> = ({ parameters, isReserved }) => {
const { t } = useTranslation()
return (
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs font-normal leading-[18px] text-text-secondary">
<thead className="uppercase text-text-tertiary">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">
{t('createTool.name', { ns: 'tools' })}
</th>
<th className="p-2 pl-3 font-medium">
{t('createTool.toolOutput.description', { ns: 'tools' })}
</th>
</tr>
</thead>
<tbody>
{parameters.map(item => (
<OutputRow
key={item.name}
item={item}
isReserved={isReserved}
/>
))}
</tbody>
</table>
</div>
)
}
export default React.memo(ToolOutputTable)

View File

@ -1,6 +1,7 @@
import type { WorkflowToolModalPayload } from './index'
import type { WorkflowToolProviderResponse } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@ -9,6 +10,33 @@ import WorkflowToolConfigureButton from './configure-button'
import WorkflowToolAsModal from './index'
import MethodSelector from './method-selector'
// Create a fresh QueryClient for each test
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: 0,
},
},
})
// Wrapper component for tests that need QueryClientProvider
const TestWrapper = ({ children }: { children: React.ReactNode }) => {
const queryClient = createTestQueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
// Custom render function that wraps with QueryClientProvider
const renderWithQueryClient = (ui: React.ReactElement) => {
return render(ui, { wrapper: TestWrapper })
}
// Mock Next.js navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
@ -39,6 +67,22 @@ vi.mock('@/service/tools', () => ({
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
}))
// Mock @/service/base for React Query hooks
vi.mock('@/service/base', () => ({
get: (url: string) => {
if (url.includes('/tool-provider/workflow/detail'))
return mockFetchWorkflowToolDetailByAppID(url.split('workflow_app_id=')[1])
return Promise.resolve({})
},
post: (url: string, options: { body: unknown }) => {
if (url.includes('/tool-provider/workflow/create'))
return mockCreateWorkflowToolProvider(options.body)
if (url.includes('/tool-provider/workflow/update'))
return mockSaveWorkflowToolProvider(options.body)
return Promise.resolve({})
},
}))
// Mock invalidate workflow tools hook
const mockInvalidateAllWorkflowTools = vi.fn()
vi.mock('@/service/use-tools', () => ({
@ -252,7 +296,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps()
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument()
@ -263,7 +307,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: false })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
expect(screen.getByText('workflow.common.configureRequired')).toBeInTheDocument()
@ -274,7 +318,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
@ -287,7 +331,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ disabled: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
const container = document.querySelector('.cursor-not-allowed')
@ -301,7 +345,7 @@ describe('WorkflowToolConfigureButton', () => {
})
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
expect(screen.getByText('Please save the workflow first')).toBeInTheDocument()
@ -313,7 +357,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
@ -327,7 +371,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
@ -342,7 +386,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps()
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
const textElement = screen.getByText('workflow.common.workflowAsTool')
@ -357,7 +401,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps()
// Act & Assert - should not throw
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
expect(() => renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
})
it('should handle undefined inputs and outputs', () => {
@ -368,7 +412,7 @@ describe('WorkflowToolConfigureButton', () => {
})
// Act & Assert
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
expect(() => renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
})
it('should handle empty inputs and outputs arrays', () => {
@ -379,7 +423,7 @@ describe('WorkflowToolConfigureButton', () => {
})
// Act & Assert
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
expect(() => renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
})
it('should call handlePublish when updating workflow tool', async () => {
@ -390,7 +434,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: true, handlePublish })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
await waitFor(() => {
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
})
@ -423,7 +467,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
@ -436,7 +480,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: true, detailNeedUpdate: false })
// Act
const { rerender } = render(<WorkflowToolConfigureButton {...props} />)
const { rerender } = renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
await waitFor(() => {
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledTimes(1)
@ -457,7 +501,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps()
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Click to open modal
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
@ -475,7 +519,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ disabled: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(triggerArea!)
@ -484,29 +528,23 @@ describe('WorkflowToolConfigureButton', () => {
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
})
it('should not open modal when published (use configure button instead)', async () => {
it('should open modal when clicking main area while published', async () => {
// Arrange
const user = userEvent.setup()
const props = createDefaultConfigureButtonProps({ published: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
await waitFor(() => {
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
})
// Click the main area (should not open modal)
// Click the main area (should open modal)
const mainArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(mainArea!)
// Should not open modal from main click
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
// Click configure button
await user.click(screen.getByText('workflow.common.configure'))
// Assert
// Assert - modal should open from main area click
await waitFor(() => {
expect(screen.getByTestId('drawer')).toBeInTheDocument()
})
@ -528,7 +566,7 @@ describe('WorkflowToolConfigureButton', () => {
})
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert - should show outdated warning
await waitFor(() => {
@ -546,7 +584,7 @@ describe('WorkflowToolConfigureButton', () => {
})
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
@ -564,7 +602,7 @@ describe('WorkflowToolConfigureButton', () => {
})
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
@ -582,7 +620,7 @@ describe('WorkflowToolConfigureButton', () => {
})
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
@ -600,7 +638,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
await waitFor(() => {
expect(screen.getByText('workflow.common.manageInTools')).toBeInTheDocument()
@ -619,7 +657,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps()
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Open modal
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
@ -649,7 +687,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps()
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(triggerArea!)
@ -679,7 +717,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps()
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(triggerArea!)
@ -710,7 +748,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ onRefreshData })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(triggerArea!)
@ -737,7 +775,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps()
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(triggerArea!)
@ -760,21 +798,31 @@ describe('WorkflowToolConfigureButton', () => {
// 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 })
it('should handle API returning minimal data', async () => {
// Arrange - API returns minimal data (simulating edge case response)
const minimalDetail = {
...createMockWorkflowToolDetail(),
tool: {
...createMockWorkflowToolDetail().tool,
parameters: [],
output_schema: { type: 'object', properties: {} },
},
}
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(minimalDetail)
const props = createDefaultConfigureButtonProps({ published: true, inputs: [] })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert - should not crash and wait for API call
await waitFor(() => {
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalled()
})
// Component should still render without crashing
expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument()
// Component should still render without crashing - check for main text
await waitFor(() => {
expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument()
})
})
it('should handle rapid publish/unpublish state changes', async () => {
@ -782,7 +830,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: false })
// Act
const { rerender } = render(<WorkflowToolConfigureButton {...props} />)
const { rerender } = renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Toggle published state rapidly
await act(async () => {
@ -807,7 +855,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: true, inputs: [] })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
@ -824,7 +872,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: true })
// Act & Assert
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
expect(() => renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
})
it('should handle paragraph type input conversion', async () => {
@ -835,7 +883,7 @@ describe('WorkflowToolConfigureButton', () => {
})
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(triggerArea!)
@ -854,7 +902,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
@ -869,7 +917,7 @@ describe('WorkflowToolConfigureButton', () => {
const props = createDefaultConfigureButtonProps({ published: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
@ -1864,7 +1912,7 @@ describe('Integration Tests', () => {
const props = createDefaultConfigureButtonProps({ onRefreshData })
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Open modal
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
@ -1916,7 +1964,7 @@ describe('Integration Tests', () => {
})
// Act
render(<WorkflowToolConfigureButton {...props} />)
renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
// Wait for detail to load
await waitFor(() => {
@ -1964,7 +2012,7 @@ describe('Integration Tests', () => {
})
// Act
const { rerender } = render(<WorkflowToolConfigureButton {...props} />)
const { rerender } = renderWithQueryClient(<WorkflowToolConfigureButton {...props} />)
rerender(<WorkflowToolConfigureButton {...props} />)
rerender(<WorkflowToolConfigureButton {...props} />)

View File

@ -1,22 +1,18 @@
'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 Divider from '@/app/components/base/divider'
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
@ -33,6 +29,99 @@ type Props = {
disabledReason?: string
}
type UnpublishedCardProps = {
disabled: boolean
isManager: boolean
onConfigureClick: () => void
}
const UnpublishedCard = ({ disabled, isManager, onConfigureClick }: UnpublishedCardProps) => {
const { t } = useTranslation()
const handleClick = () => {
if (!disabled && isManager)
onConfigureClick()
}
return (
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
onClick={handleClick}
>
<RiHammerLine className={cn('relative h-4 w-4 text-text-secondary', !disabled && isManager && '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 && isManager && 'group-hover:text-text-accent')}
>
{t('common.workflowAsTool', { ns: 'workflow' })}
</div>
<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">
{t('common.configureRequired', { ns: 'workflow' })}
</span>
</div>
)
}
type NonManagerCardProps = Record<string, never>
const NonManagerCard = (_props: NonManagerCardProps) => {
const { t } = useTranslation()
return (
<div className="flex items-center justify-start gap-2 p-2 pl-2.5">
<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"
>
{t('common.workflowAsTool', { ns: 'workflow' })}
</div>
</div>
)
}
type PublishedActionsProps = {
disabled: boolean
isManager: boolean
outdated: boolean
onConfigureClick: () => void
onManageClick: () => void
}
const PublishedActions = ({ disabled, isManager, outdated, onConfigureClick, onManageClick }: PublishedActionsProps) => {
const { t } = useTranslation()
return (
<div className="border-t-[0.5px] border-divider-regular px-2.5 py-2">
<div className="flex justify-between gap-x-2">
<Button
size="small"
className="w-[140px]"
onClick={onConfigureClick}
disabled={!isManager || disabled}
>
{t('common.configure', { ns: 'workflow' })}
{outdated && <Indicator className="ml-1" color="yellow" />}
</Button>
<Button
size="small"
className="w-[140px]"
onClick={onManageClick}
disabled={disabled}
>
{t('common.manageInTools', { ns: 'workflow' })}
<RiArrowRightUpLine className="ml-1 h-4 w-4" />
</Button>
</div>
{outdated && (
<div className="mt-1 text-xs leading-[18px] text-text-warning">
{t('common.workflowAsToolTip', { ns: 'workflow' })}
</div>
)}
</div>
)
}
const WorkflowToolConfigureButton = ({
disabled,
published,
@ -49,229 +138,96 @@ const WorkflowToolConfigureButton = ({
}: 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 {
showModal,
isLoading,
outdated,
payload,
openModal,
closeModal,
handleCreate,
handleUpdate,
} = useConfigureButton({
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
})
const payload = useMemo(() => {
let parameters: WorkflowToolProviderParameter[] = []
let outputParameters: WorkflowToolProviderOutputParameter[] = []
const handleUnpublishedClick = () => {
if (!disabled)
openModal()
}
const handleManageClick = () => {
router.push('/tools?category=workflow')
}
const cardClassName = cn(
'group rounded-lg bg-background-section-burn transition-colors',
disabled || !isCurrentWorkspaceManager ? 'cursor-not-allowed opacity-60 shadow-xs' : 'cursor-pointer',
!disabled && !published && isCurrentWorkspaceManager && 'hover:bg-state-accent-hover',
)
const renderCardContent = () => {
if (!isCurrentWorkspaceManager)
return <NonManagerCard />
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,
}
})
return (
<UnpublishedCard
disabled={disabled}
isManager={isCurrentWorkspaceManager}
onConfigureClick={handleUnpublishedClick}
/>
)
}
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 })
}
return (
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
onClick={openModal}
>
<RiHammerLine className="relative h-4 w-4 text-text-secondary" />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className="system-sm-medium shrink grow basis-0 truncate text-text-secondary"
>
{t('common.workflowAsTool', { ns: 'workflow' })}
</div>
</div>
)
}
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 showContent = !published || !isLoading
return (
<>
<Divider type="horizontal" className="h-px bg-divider-subtle" />
{(!published || !isLoading) && (
<div className={cn(
'group rounded-lg bg-background-section-burn transition-colors',
disabled || !isCurrentWorkspaceManager ? 'cursor-not-allowed opacity-60 shadow-xs' : 'cursor-pointer',
!disabled && !published && isCurrentWorkspaceManager && 'hover:bg-state-accent-hover',
)}
>
{isCurrentWorkspaceManager
? (
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
onClick={() => !disabled && !published && setShowModal(true)}
>
<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')}
>
{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">
{t('common.configureRequired', { ns: 'workflow' })}
</span>
)}
</div>
)
: (
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
>
<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"
>
{t('common.workflowAsTool', { ns: 'workflow' })}
</div>
</div>
)}
{showContent && (
<div className={cardClassName}>
{renderCardContent()}
{disabledReason && (
<div className="mt-1 px-2.5 pb-2 text-xs leading-[18px] text-text-tertiary">
{disabledReason}
</div>
)}
{published && (
<div className="border-t-[0.5px] border-divider-regular px-2.5 py-2">
<div className="flex justify-between gap-x-2">
<Button
size="small"
className="w-[140px]"
onClick={() => setShowModal(true)}
disabled={!isCurrentWorkspaceManager || disabled}
>
{t('common.configure', { ns: 'workflow' })}
{outdated && <Indicator className="ml-1" color="yellow" />}
</Button>
<Button
size="small"
className="w-[140px]"
onClick={() => router.push('/tools?category=workflow')}
disabled={disabled}
>
{t('common.manageInTools', { ns: 'workflow' })}
<RiArrowRightUpLine className="ml-1 h-4 w-4" />
</Button>
</div>
{outdated && (
<div className="mt-1 text-xs leading-[18px] text-text-warning">
{t('common.workflowAsToolTip', { ns: 'workflow' })}
</div>
)}
</div>
<PublishedActions
disabled={disabled}
isManager={isCurrentWorkspaceManager}
outdated={outdated}
onConfigureClick={openModal}
onManageClick={handleManageClick}
/>
)}
</div>
)}
@ -280,12 +236,13 @@ const WorkflowToolConfigureButton = ({
<WorkflowToolModal
isAdd={!published}
payload={payload}
onHide={() => setShowModal(false)}
onCreate={createHandle}
onSave={updateWorkflowToolProvider}
onHide={closeModal}
onCreate={handleCreate}
onSave={handleUpdate}
/>
)}
</>
)
}
export default WorkflowToolConfigureButton

View File

@ -0,0 +1,222 @@
'use client'
import type { Emoji, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useBoolean } from 'ahooks'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useInvalidateAllWorkflowTools } from '@/service/use-tools'
import {
useCreateWorkflowTool,
useInvalidateWorkflowToolDetail,
useUpdateWorkflowTool,
useWorkflowToolDetail,
} from './use-workflow-tool'
export type ConfigureButtonProps = {
published: boolean
detailNeedUpdate?: boolean
workflowAppId: string
icon: Emoji
name: string
description: string
inputs?: InputVar[]
outputs?: Variable[]
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
onRefreshData?: () => void
}
// Type for parameter building context
type ParameterBuildContext = {
inputs: InputVar[] | undefined
outputs: Variable[] | undefined
detail: WorkflowToolProviderResponse | undefined
published: boolean
}
/**
* Check if tool parameters are outdated compared to workflow inputs
*/
function checkOutdated(detail: WorkflowToolProviderResponse | undefined, inputs: InputVar[] | undefined): boolean {
if (!detail)
return false
const toolParams = detail.tool.parameters
const inputList = inputs ?? []
if (toolParams.length !== inputList.length)
return true
return inputList.some((item) => {
const param = toolParams.find(p => p.name === item.variable)
if (!param || param.required !== item.required)
return true
const isTextType = item.type === 'paragraph' || item.type === 'text-input'
return isTextType && param.type !== 'string'
})
}
/**
* Build input parameters based on context
*/
function buildInputParameters(ctx: ParameterBuildContext): WorkflowToolProviderParameter[] {
const inputList = ctx.inputs ?? []
if (!ctx.published || !ctx.detail?.tool) {
return inputList.map(item => ({
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}))
}
const existingParams = ctx.detail.tool.parameters
return inputList.map((item) => {
const existing = existingParams.find(p => p.name === item.variable)
return {
name: item.variable,
required: item.required,
type: item.type === 'paragraph' ? 'string' : item.type,
description: existing?.llm_description ?? '',
form: existing?.form ?? 'llm',
}
})
}
/**
* Build output parameters
*/
function buildOutputParameters(outputs: Variable[] | undefined, detail?: WorkflowToolProviderResponse) {
return (outputs ?? []).map((item) => {
const found = detail?.tool.output_schema?.properties?.[item.variable]
return {
name: item.variable,
description: found?.description ?? '',
type: item.value_type,
}
})
}
/**
* Custom hook for managing configure button state and logic
*/
export const useConfigureButton = ({
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
}: ConfigureButtonProps) => {
const { t } = useTranslation()
const [showModal, { setTrue: openModal, setFalse: closeModal }] = useBoolean(false)
// Data fetching with React Query
const {
data: detail,
isLoading,
refetch: refetchDetail,
} = useWorkflowToolDetail(workflowAppId, published)
// Refetch detail when external updates occur
useEffect(() => {
if (detailNeedUpdate)
refetchDetail()
}, [detailNeedUpdate, refetchDetail])
// Mutations
const { mutateAsync: createTool } = useCreateWorkflowTool()
const { mutateAsync: updateTool } = useUpdateWorkflowTool()
const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools()
const invalidateDetail = useInvalidateWorkflowToolDetail()
// Check if parameters are outdated
const outdated = useMemo(
() => checkOutdated(detail, inputs),
[detail, inputs],
)
// Build payload for modal
const payload = useMemo(() => {
const ctx: ParameterBuildContext = { inputs, outputs, detail, published }
const parameters = buildInputParameters(ctx)
const outputParameters = buildOutputParameters(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 ?? '',
tool: detail?.tool,
...(published
? { workflow_tool_id: detail?.workflow_tool_id }
: { workflow_app_id: workflowAppId }),
}
}, [detail, published, workflowAppId, icon, name, description, inputs, outputs])
// Common cache invalidation logic
const invalidateCaches = useCallback(() => {
invalidateAllWorkflowTools()
invalidateDetail(workflowAppId)
onRefreshData?.()
refetchDetail()
}, [invalidateAllWorkflowTools, invalidateDetail, workflowAppId, onRefreshData, refetchDetail])
// Common success handler
const handleSuccess = useCallback(() => {
Toast.notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }) })
closeModal()
}, [t, closeModal])
// Handler for creating new workflow tool
const handleCreate = useCallback(async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => {
try {
await createTool(data)
invalidateCaches()
handleSuccess()
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}, [createTool, invalidateCaches, handleSuccess])
// Handler for updating workflow tool
const handleUpdate = useCallback(async (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>) => {
try {
await handlePublish()
await updateTool(data)
invalidateCaches()
handleSuccess()
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}, [handlePublish, updateTool, invalidateCaches, handleSuccess])
return {
showModal,
isLoading,
detail,
outdated,
payload,
openModal,
closeModal,
handleCreate,
handleUpdate,
}
}

View File

@ -0,0 +1,62 @@
'use client'
import { useBoolean } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
export type ModalStateResult = {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}
/**
* Simple hook for managing modal open/close state
*/
export const useModalState = (initialState = false): ModalStateResult => {
const [isOpen, { setTrue: open, setFalse: close, toggle }] = useBoolean(initialState)
return { isOpen, open, close, toggle }
}
type ModalActions = {
isOpen: boolean
open: () => void
close: () => void
}
/**
* Hook for managing multiple modal states
* Uses a single useState to avoid violating Rules of Hooks
*/
export const useMultiModalState = <T extends string>(modalNames: T[]) => {
// Use a single state object to track all modal open states
const [openStates, setOpenStates] = useState<Record<T, boolean>>(() =>
modalNames.reduce((acc, name) => {
acc[name] = false
return acc
}, {} as Record<T, boolean>),
)
// Create memoized modal accessors with open/close callbacks
const modals = useMemo(() => {
return modalNames.reduce((acc, name) => {
acc[name] = {
isOpen: openStates[name] ?? false,
open: () => setOpenStates(prev => ({ ...prev, [name]: true })),
close: () => setOpenStates(prev => ({ ...prev, [name]: false })),
}
return acc
}, {} as Record<T, ModalActions>)
}, [modalNames, openStates])
// Helper to close all modals
const closeAll = useCallback(() => {
setOpenStates(prev =>
modalNames.reduce((acc, name) => {
acc[name] = false
return acc
}, { ...prev } as Record<T, boolean>),
)
}, [modalNames])
return { modals, closeAll }
}

View File

@ -0,0 +1,240 @@
'use client'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '@/app/components/tools/types'
import { produce } from 'immer'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { VarType } from '@/app/components/workflow/types'
import { buildWorkflowOutputParameters } from '../utils'
export type WorkflowToolFormPayload = {
icon: Emoji
label: string
name: string
description: string
parameters: WorkflowToolProviderParameter[]
outputParameters?: WorkflowToolProviderOutputParameter[] | null
labels: string[]
privacy_policy: string
workflow_app_id?: string
workflow_tool_id?: string
tool?: {
output_schema?: WorkflowToolProviderOutputSchema | null
}
}
export type UseWorkflowToolFormProps = {
payload: WorkflowToolFormPayload
isAdd?: boolean
onCreate?: (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
onSave?: (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>) => void
}
type FormState = {
emoji: Emoji
label: string
name: string
description: string
parameters: WorkflowToolProviderParameter[]
labels: string[]
privacyPolicy: string
}
/**
* Validate tool name format (alphanumeric and underscores only)
*/
const isNameValid = (name: string): boolean => {
if (name === '')
return true
return /^\w+$/.test(name)
}
/**
* Custom hook for managing workflow tool form state and logic
*/
export const useWorkflowToolForm = ({
payload,
isAdd,
onCreate,
onSave,
}: UseWorkflowToolFormProps) => {
const { t } = useTranslation()
// Form state
const [formState, setFormState] = useState<FormState>({
emoji: payload.icon,
label: payload.label,
name: payload.name,
description: payload.description,
parameters: payload.parameters,
labels: payload.labels,
privacyPolicy: payload.privacy_policy,
})
// Computed output parameters (from payload.outputParameters or derived from tool.output_schema)
const outputParameters = useMemo<WorkflowToolProviderOutputParameter[]>(
() => buildWorkflowOutputParameters(payload.outputParameters ?? null, payload.tool?.output_schema ?? null),
[payload.outputParameters, payload.tool?.output_schema],
)
// Reserved output parameters (text, files, json)
const reservedOutputParameters = useMemo<WorkflowToolProviderOutputParameter[]>(() => [
{
name: 'text',
description: t('nodes.tool.outputVars.text', { ns: 'workflow' }),
type: VarType.string,
reserved: true,
},
{
name: 'files',
description: t('nodes.tool.outputVars.files.title', { ns: 'workflow' }),
type: VarType.arrayFile,
reserved: true,
},
{
name: 'json',
description: t('nodes.tool.outputVars.json', { ns: 'workflow' }),
type: VarType.arrayObject,
reserved: true,
},
], [t])
// Check if output parameter name conflicts with reserved names
const isOutputParameterReserved = useCallback((name: string) => {
return reservedOutputParameters.some(p => p.name === name)
}, [reservedOutputParameters])
// State update handlers
const setEmoji = useCallback((emoji: Emoji) => {
setFormState(prev => ({ ...prev, emoji }))
}, [])
const setLabel = useCallback((label: string) => {
setFormState(prev => ({ ...prev, label }))
}, [])
const setName = useCallback((name: string) => {
setFormState(prev => ({ ...prev, name }))
}, [])
const setDescription = useCallback((description: string) => {
setFormState(prev => ({ ...prev, description }))
}, [])
const setLabels = useCallback((labels: string[]) => {
setFormState(prev => ({ ...prev, labels }))
}, [])
const setPrivacyPolicy = useCallback((privacyPolicy: string) => {
setFormState(prev => ({ ...prev, privacyPolicy }))
}, [])
// Handle parameter change (description or form/method)
const handleParameterChange = useCallback((key: 'description' | 'form', value: string, index: number) => {
setFormState((prev) => {
const newParameters = produce(prev.parameters, (draft) => {
if (key === 'description')
draft[index].description = value
else
draft[index].form = value
})
return { ...prev, parameters: newParameters }
})
}, [])
// Validate form and show error toast if invalid
const validateForm = useCallback((): boolean => {
if (!formState.label) {
Toast.notify({
type: 'error',
message: t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.name', { ns: 'tools' }) }),
})
return false
}
if (!formState.name) {
Toast.notify({
type: 'error',
message: t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.nameForToolCall', { ns: 'tools' }) }),
})
return false
}
if (!isNameValid(formState.name)) {
Toast.notify({
type: 'error',
message: t('createTool.nameForToolCall', { ns: 'tools' }) + t('createTool.nameForToolCallTip', { ns: 'tools' }),
})
return false
}
return true
}, [formState.label, formState.name, t])
// Build request params for API
const buildRequestParams = useCallback((): WorkflowToolProviderRequest => ({
name: formState.name,
description: formState.description,
icon: formState.emoji,
label: formState.label,
parameters: formState.parameters.map(item => ({
name: item.name,
description: item.description,
form: item.form,
})),
labels: formState.labels,
privacy_policy: formState.privacyPolicy,
}), [formState])
// Submit form
const onConfirm = useCallback(() => {
if (!validateForm())
return
const requestParams = buildRequestParams()
if (isAdd) {
onCreate?.({
...requestParams,
workflow_app_id: payload.workflow_app_id!,
})
}
else {
onSave?.({
...requestParams,
workflow_tool_id: payload.workflow_tool_id,
})
}
}, [validateForm, buildRequestParams, isAdd, onCreate, onSave, payload.workflow_app_id, payload.workflow_tool_id])
return {
// Form state
emoji: formState.emoji,
label: formState.label,
name: formState.name,
description: formState.description,
parameters: formState.parameters,
labels: formState.labels,
privacyPolicy: formState.privacyPolicy,
// Computed values
outputParameters,
reservedOutputParameters,
allOutputParameters: [...reservedOutputParameters, ...outputParameters],
isNameValid: isNameValid(formState.name),
// Handlers
setEmoji,
setLabel,
setName,
setDescription,
setLabels,
setPrivacyPolicy,
handleParameterChange,
isOutputParameterReserved,
onConfirm,
}
}

View File

@ -0,0 +1,70 @@
import type { WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
import {
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { get, post } from '@/service/base'
const NAME_SPACE = 'workflow-tool'
// Query key factory for workflow tool detail
const workflowToolDetailKey = (appId: string) => [NAME_SPACE, 'detail', appId]
/**
* Fetch workflow tool detail by app ID
*/
export const useWorkflowToolDetail = (appId: string, enabled = true) => {
return useQuery<WorkflowToolProviderResponse>({
queryKey: workflowToolDetailKey(appId),
queryFn: () => get<WorkflowToolProviderResponse>(`/workspaces/current/tool-provider/workflow/detail?workflow_app_id=${appId}`),
enabled: enabled && !!appId,
})
}
/**
* Invalidate workflow tool detail cache
*/
export const useInvalidateWorkflowToolDetail = () => {
const queryClient = useQueryClient()
return (appId: string) => {
queryClient.invalidateQueries({
queryKey: workflowToolDetailKey(appId),
})
}
}
type CreateWorkflowToolPayload = WorkflowToolProviderRequest & { workflow_app_id: string }
/**
* Create workflow tool provider mutation
*/
export const useCreateWorkflowTool = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'create'],
mutationFn: (payload: CreateWorkflowToolPayload) => {
return post('/workspaces/current/tool-provider/workflow/create', {
body: payload,
})
},
})
}
type UpdateWorkflowToolPayload = WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>
/**
* Update workflow tool provider mutation
*/
export const useUpdateWorkflowTool = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'update'],
mutationFn: (payload: UpdateWorkflowToolPayload) => {
return post('/workspaces/current/tool-provider/workflow/update', {
body: payload,
})
},
})
}

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,8 @@
'use client'
import type { FC } from 'react'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import { RiErrorWarningLine } from '@remixicon/react'
import { produce } from 'immer'
import type { WorkflowToolFormPayload } from './hooks/use-workflow-tool-form'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
@ -12,14 +10,14 @@ import Drawer from '@/app/components/base/drawer-plus'
import EmojiPicker from '@/app/components/base/emoji-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import LabelSelector from '@/app/components/tools/labels/selector'
import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal'
import MethodSelector from '@/app/components/tools/workflow-tool/method-selector'
import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import { buildWorkflowOutputParameters } from './utils'
import ToolInputTable from './components/tool-input-table'
import ToolOutputTable from './components/tool-output-table'
import { useModalState } from './hooks/use-modal-state'
import { useWorkflowToolForm } from './hooks/use-workflow-tool-form'
export type WorkflowToolModalPayload = {
icon: Emoji
@ -39,7 +37,7 @@ export type WorkflowToolModalPayload = {
type Props = {
isAdd?: boolean
payload: WorkflowToolModalPayload
payload: WorkflowToolFormPayload
onHide: () => void
onRemove?: () => void
onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
@ -48,7 +46,64 @@ type Props = {
workflow_tool_id: string
}>) => void
}
// Add and Edit
// Form field wrapper component
type FormFieldProps = {
label: string
required?: boolean
tooltip?: string
children: React.ReactNode
}
const FormField: FC<FormFieldProps> = ({ label, required, tooltip, children }) => (
<div>
<div className="system-sm-medium flex items-center py-2 text-text-primary">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
{tooltip && (
<Tooltip popupContent={<div className="w-[180px]">{tooltip}</div>} />
)}
</div>
{children}
</div>
)
// Footer actions component
type FooterActionsProps = {
isAdd?: boolean
onRemove?: () => void
onHide: () => void
onSaveClick: () => void
}
const FooterActions: FC<FooterActionsProps> = ({ isAdd, onRemove, onHide, onSaveClick }) => {
const { t } = useTranslation()
const showDeleteButton = !isAdd && onRemove
return (
<div className={cn(
showDeleteButton ? 'justify-between' : 'justify-end',
'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4',
)}
>
{showDeleteButton && (
<Button variant="warning" onClick={onRemove}>
{t('operation.delete', { ns: 'common' })}
</Button>
)}
<div className="flex space-x-2">
<Button onClick={onHide}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="primary" onClick={onSaveClick}>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</div>
)
}
// Main component
const WorkflowToolAsModal: FC<Props> = ({
isAdd,
payload,
@ -59,108 +114,24 @@ const WorkflowToolAsModal: FC<Props> = ({
}) => {
const { t } = useTranslation()
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false)
const [emoji, setEmoji] = useState<Emoji>(payload.icon)
const [label, setLabel] = useState<string>(payload.label)
const [name, setName] = useState(payload.name)
const [description, setDescription] = useState(payload.description)
const [parameters, setParameters] = useState<WorkflowToolProviderParameter[]>(payload.parameters)
const rawOutputParameters = payload.outputParameters
const outputSchema = payload.tool?.output_schema
const outputParameters = useMemo<WorkflowToolProviderOutputParameter[]>(() => buildWorkflowOutputParameters(rawOutputParameters, outputSchema), [rawOutputParameters, outputSchema])
const reservedOutputParameters: WorkflowToolProviderOutputParameter[] = [
{
name: 'text',
description: t('nodes.tool.outputVars.text', { ns: 'workflow' }),
type: VarType.string,
reserved: true,
},
{
name: 'files',
description: t('nodes.tool.outputVars.files.title', { ns: 'workflow' }),
type: VarType.arrayFile,
reserved: true,
},
{
name: 'json',
description: t('nodes.tool.outputVars.json', { ns: 'workflow' }),
type: VarType.arrayObject,
reserved: true,
},
]
// Modal states
const emojiPicker = useModalState(false)
const confirmModal = useModalState(false)
const handleParameterChange = (key: string, value: string, index: number) => {
const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => {
if (key === 'description')
draft[index].description = value
else
draft[index].form = value
})
setParameters(newData)
}
const [labels, setLabels] = useState<string[]>(payload.labels)
const handleLabelSelect = (value: string[]) => {
setLabels(value)
}
const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy)
const [showModal, setShowModal] = useState(false)
// Form state and logic
const form = useWorkflowToolForm({
payload,
isAdd,
onCreate,
onSave,
})
const isNameValid = (name: string) => {
// when the user has not input anything, no need for a warning
if (name === '')
return true
return /^\w+$/.test(name)
}
const isOutputParameterReserved = (name: string) => {
return reservedOutputParameters.find(p => p.name === name)
}
const onConfirm = () => {
let errorMessage = ''
if (!label)
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.name', { ns: 'tools' }) })
if (!name)
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.nameForToolCall', { ns: 'tools' }) })
if (!isNameValid(name))
errorMessage = t('createTool.nameForToolCall', { ns: 'tools' }) + t('createTool.nameForToolCallTip', { ns: 'tools' })
if (errorMessage) {
Toast.notify({
type: 'error',
message: errorMessage,
})
return
}
const requestParams = {
name,
description,
icon: emoji,
label,
parameters: parameters.map(item => ({
name: item.name,
description: item.description,
form: item.form,
})),
labels,
privacy_policy: privacyPolicy,
}
if (!isAdd) {
onSave?.({
...requestParams,
workflow_tool_id: payload.workflow_tool_id!,
})
}
else {
onCreate?.({
...requestParams,
workflow_app_id: payload.workflow_app_id!,
})
}
// Handle save button click
const handleSaveClick = () => {
if (isAdd)
form.onConfirm()
else
confirmModal.open()
}
return (
@ -176,217 +147,119 @@ const WorkflowToolAsModal: FC<Props> = ({
body={(
<div className="flex h-full flex-col">
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
{/* name & icon */}
<div>
<div className="system-sm-medium py-2 text-text-primary">
{t('createTool.name', { ns: 'tools' })}
{' '}
<span className="ml-1 text-red-500">*</span>
</div>
{/* Name & Icon */}
<FormField label={t('createTool.name', { ns: 'tools' })} required>
<div className="flex items-center justify-between gap-3">
<AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} />
<AppIcon
size="large"
onClick={emojiPicker.open}
className="cursor-pointer"
iconType="emoji"
icon={form.emoji.content}
background={form.emoji.background}
/>
<Input
className="h-10 grow"
placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
value={label}
onChange={e => setLabel(e.target.value)}
/>
</div>
</div>
{/* name for tool call */}
<div>
<div className="system-sm-medium flex items-center py-2 text-text-primary">
{t('createTool.nameForToolCall', { ns: 'tools' })}
{' '}
<span className="ml-1 text-red-500">*</span>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
</div>
)}
value={form.label}
onChange={e => form.setLabel(e.target.value)}
/>
</div>
</FormField>
{/* Name for Tool Call */}
<FormField
label={t('createTool.nameForToolCall', { ns: 'tools' })}
required
tooltip={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
>
<Input
className="h-10"
placeholder={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })!}
value={name}
onChange={e => setName(e.target.value)}
value={form.name}
onChange={e => form.setName(e.target.value)}
/>
{!isNameValid(name) && (
<div className="text-xs leading-[18px] text-red-500">{t('createTool.nameForToolCallTip', { ns: 'tools' })}</div>
{!form.isNameValid && (
<div className="text-xs leading-[18px] text-red-500">
{t('createTool.nameForToolCallTip', { ns: 'tools' })}
</div>
)}
</div>
{/* description */}
<div>
<div className="system-sm-medium py-2 text-text-primary">{t('createTool.description', { ns: 'tools' })}</div>
</FormField>
{/* Description */}
<FormField label={t('createTool.description', { ns: 'tools' })}>
<Textarea
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
value={description}
onChange={e => setDescription(e.target.value)}
value={form.description}
onChange={e => form.setDescription(e.target.value)}
/>
</div>
{/* Tool Input */}
<div>
<div className="system-sm-medium py-2 text-text-primary">{t('createTool.toolInput.title', { ns: 'tools' })}</div>
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs font-normal leading-[18px] text-text-secondary">
<thead className="uppercase text-text-tertiary">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.toolInput.name', { ns: 'tools' })}</th>
<th className="w-[102px] p-2 pl-3 font-medium">{t('createTool.toolInput.method', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.toolInput.description', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{parameters.map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex">
<span className="truncate font-medium text-text-primary">{item.name}</span>
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.required ? t('createTool.toolInput.required', { ns: 'tools' }) : ''}</span>
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td>
{item.name === '__image' && (
<div className={cn(
'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2',
)}
>
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
{t('createTool.toolInput.methodParameter', { ns: 'tools' })}
</div>
</div>
)}
{item.name !== '__image' && (
<MethodSelector value={item.form} onChange={value => handleParameterChange('form', value, index)} />
)}
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<input
type="text"
className="w-full appearance-none bg-transparent text-[13px] font-normal leading-[18px] text-text-secondary caret-primary-600 outline-none placeholder:text-text-quaternary"
placeholder={t('createTool.toolInput.descriptionPlaceholder', { ns: 'tools' })!}
value={item.description}
onChange={e => handleParameterChange('description', e.target.value, index)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Tool Output */}
<div>
<div className="system-sm-medium py-2 text-text-primary">{t('createTool.toolOutput.title', { ns: 'tools' })}</div>
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs font-normal leading-[18px] text-text-secondary">
<thead className="uppercase text-text-tertiary">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.name', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.toolOutput.description', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{[...reservedOutputParameters, ...outputParameters].map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex items-center">
<span className="truncate font-medium text-text-primary">{item.name}</span>
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.reserved ? t('createTool.toolOutput.reserved', { ns: 'tools' }) : ''}</span>
{
!item.reserved && isOutputParameterReserved(item.name)
? (
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })}
</div>
)}
>
<RiErrorWarningLine className="h-3 w-3 text-text-warning-secondary" />
</Tooltip>
)
: null
}
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<span className="text-[13px] font-normal leading-[18px] text-text-secondary">{item.description}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</FormField>
{/* Tool Input */}
<FormField label={t('createTool.toolInput.title', { ns: 'tools' })}>
<ToolInputTable
parameters={form.parameters}
onParameterChange={form.handleParameterChange}
/>
</FormField>
{/* Tool Output */}
<FormField label={t('createTool.toolOutput.title', { ns: 'tools' })}>
<ToolOutputTable
parameters={form.allOutputParameters}
isReserved={form.isOutputParameterReserved}
/>
</FormField>
{/* Tags */}
<div>
<div className="system-sm-medium py-2 text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
<LabelSelector value={labels} onChange={handleLabelSelect} />
</div>
<FormField label={t('createTool.toolInput.label', { ns: 'tools' })}>
<LabelSelector value={form.labels} onChange={form.setLabels} />
</FormField>
{/* Privacy Policy */}
<div>
<div className="system-sm-medium py-2 text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
<FormField label={t('createTool.privacyPolicy', { ns: 'tools' })}>
<Input
className="h-10"
value={privacyPolicy}
onChange={e => setPrivacyPolicy(e.target.value)}
value={form.privacyPolicy}
onChange={e => form.setPrivacyPolicy(e.target.value)}
placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''}
/>
</div>
</div>
<div className={cn((!isAdd && onRemove) ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}>
{!isAdd && onRemove && (
<Button variant="warning" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button>
)}
<div className="flex space-x-2 ">
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
variant="primary"
onClick={() => {
if (isAdd)
onConfirm()
else
setShowModal(true)
}}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</FormField>
</div>
<FooterActions
isAdd={isAdd}
onRemove={onRemove}
onHide={onHide}
onSaveClick={handleSaveClick}
/>
</div>
)}
isShowMask={true}
clickOutsideNotOpen={true}
/>
{showEmojiPicker && (
{/* Emoji Picker Modal */}
{emojiPicker.isOpen && (
<EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ content: icon, background: icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
setShowEmojiPicker(false)
form.setEmoji({ content: icon, background: icon_background })
emojiPicker.close()
}}
onClose={emojiPicker.close}
/>
)}
{showModal && (
{/* Confirm Modal */}
{confirmModal.isOpen && (
<ConfirmModal
show={showModal}
onClose={() => setShowModal(false)}
onConfirm={onConfirm}
show={confirmModal.isOpen}
onClose={confirmModal.close}
onConfirm={form.onConfirm}
/>
)}
</>
)
}
export default React.memo(WorkflowToolAsModal)

View File

@ -2683,11 +2683,6 @@
"count": 3
}
},
"app/components/tools/types.ts": {
"ts/no-explicit-any": {
"count": 4
}
},
"app/components/workflow-app/components/workflow-children.tsx": {
"no-console": {
"count": 1