mirror of
https://github.com/langgenius/dify.git
synced 2026-03-31 18:00:55 -04:00
refactor(app-info): remove unused app-info component and add tests for app-info actions and dropdown menu
This commit is contained in:
31
web/app/components/app-sidebar/__tests__/app-info.spec.tsx
Normal file
31
web/app/components/app-sidebar/__tests__/app-info.spec.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import AppInfo from '../app-info'
|
||||
|
||||
vi.mock('../app-info/index', () => ({
|
||||
default: ({
|
||||
expand,
|
||||
onlyShowDetail = false,
|
||||
openState = false,
|
||||
}: {
|
||||
expand: boolean
|
||||
onlyShowDetail?: boolean
|
||||
openState?: boolean
|
||||
}) => (
|
||||
<div
|
||||
data-testid="app-info-inner"
|
||||
data-expand={String(expand)}
|
||||
data-only-show-detail={String(onlyShowDetail)}
|
||||
data-open-state={String(openState)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('app-sidebar/app-info entrypoint', () => {
|
||||
it('should forward props to the modular app-info implementation', () => {
|
||||
render(<AppInfo expand onlyShowDetail openState />)
|
||||
|
||||
expect(screen.getByTestId('app-info-inner')).toHaveAttribute('data-expand', 'true')
|
||||
expect(screen.getByTestId('app-info-inner')).toHaveAttribute('data-only-show-detail', 'true')
|
||||
expect(screen.getByTestId('app-info-inner')).toHaveAttribute('data-open-state', 'true')
|
||||
})
|
||||
})
|
||||
@@ -1,511 +0,0 @@
|
||||
import type { Operation } from './app-info/app-operations'
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
RiEqualizer2Line,
|
||||
RiExchange2Line,
|
||||
RiFileCopy2Line,
|
||||
RiFileDownloadLine,
|
||||
RiFileUploadLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
import ContentDialog from '@/app/components/base/content-dialog'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { copyApp, deleteApp, exportAppBundle, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps'
|
||||
import { useInvalidateAppList } from '@/service/use-apps'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import AppOperations from './app-info/app-operations'
|
||||
|
||||
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const Confirm = dynamic(() => import('@/app/components/base/confirm'), {
|
||||
ssr: false,
|
||||
})
|
||||
const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export type IAppInfoProps = {
|
||||
expand: boolean
|
||||
onlyShowDetail?: boolean
|
||||
openState?: boolean
|
||||
onDetailExpand?: (expand: boolean) => void
|
||||
}
|
||||
|
||||
const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailExpand }: IAppInfoProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { replace } = useRouter()
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||
const invalidateAppList = useInvalidateAppList()
|
||||
const [open, setOpen] = useState(openState)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
|
||||
const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false)
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
||||
const [showExportWarning, setShowExportWarning] = useState(false)
|
||||
const [exportSandboxed, setExportSandboxed] = useState(false)
|
||||
|
||||
const emitAppMetaUpdate = useCallback(() => {
|
||||
if (!appDetail?.id)
|
||||
return
|
||||
const socket = webSocketClient.getSocket(appDetail.id)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'app_meta_update',
|
||||
data: { timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}, [appDetail])
|
||||
|
||||
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
}) => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const app = await updateAppInfo({
|
||||
appID: appDetail.id,
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
})
|
||||
setShowEditModal(false)
|
||||
toast.success(t('editDone', { ns: 'app' }))
|
||||
setAppDetail(app)
|
||||
emitAppMetaUpdate()
|
||||
}
|
||||
catch {
|
||||
toast.error(t('editFailed', { ns: 'app' }))
|
||||
}
|
||||
}, [appDetail, setAppDetail, t, emitAppMetaUpdate])
|
||||
|
||||
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const newApp = await copyApp({
|
||||
appID: appDetail.id,
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
mode: appDetail.mode,
|
||||
})
|
||||
setShowDuplicateModal(false)
|
||||
toast.success(t('newApp.appCreated', { ns: 'app' }))
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
onPlanInfoChanged()
|
||||
getRedirection(true, newApp, replace)
|
||||
}
|
||||
catch {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
}
|
||||
|
||||
const onExport = async (include = false, sandboxed = false) => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
if (sandboxed) {
|
||||
await exportAppBundle({
|
||||
appID: appDetail.id,
|
||||
include,
|
||||
})
|
||||
return
|
||||
}
|
||||
const { data } = await exportAppConfig({
|
||||
appID: appDetail.id,
|
||||
include,
|
||||
})
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
downloadBlob({ data: file, fileName: `${appDetail.name}.yaml` })
|
||||
}
|
||||
catch {
|
||||
toast.error(t('exportFailed', { ns: 'app' }))
|
||||
}
|
||||
}
|
||||
|
||||
const exportCheck = async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
if (appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.ADVANCED_CHAT) {
|
||||
onExport(false, false)
|
||||
return
|
||||
}
|
||||
|
||||
setShowExportWarning(true)
|
||||
}
|
||||
|
||||
const handleConfirmExport = async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
setShowExportWarning(false)
|
||||
try {
|
||||
const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`)
|
||||
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
|
||||
const sandboxed = workflowDraft.features?.sandbox?.enabled === true
|
||||
if (list.length === 0) {
|
||||
onExport(false, sandboxed)
|
||||
return
|
||||
}
|
||||
setSecretEnvList(list)
|
||||
setExportSandboxed(sandboxed)
|
||||
}
|
||||
catch {
|
||||
toast.error(t('exportFailed', { ns: 'app' }))
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
await deleteApp(appDetail.id)
|
||||
toast.success(t('appDeleted', { ns: 'app' }))
|
||||
invalidateAppList()
|
||||
onPlanInfoChanged()
|
||||
setAppDetail()
|
||||
replace('/apps')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
const suffix = typeof e === 'object' && e !== null && 'message' in e
|
||||
? `: ${String((e as { message: unknown }).message)}`
|
||||
: ''
|
||||
toast.error(`${t('appDeleteFailed', { ns: 'app' })}${suffix}`)
|
||||
}
|
||||
setShowConfirmDelete(false)
|
||||
}, [appDetail, invalidateAppList, onPlanInfoChanged, replace, setAppDetail, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!appDetail?.id)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onAppMetaUpdate(async () => {
|
||||
try {
|
||||
const res = await fetchAppDetail({ url: '/apps', id: appDetail.id })
|
||||
setAppDetail({ ...res })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('failed to refresh app detail from collaboration update:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appDetail?.id, setAppDetail])
|
||||
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
|
||||
if (!appDetail)
|
||||
return null
|
||||
|
||||
const primaryOperations = [
|
||||
{
|
||||
id: 'edit',
|
||||
title: t('editApp', { ns: 'app' }),
|
||||
icon: <RiEditLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowEditModal(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
title: t('duplicate', { ns: 'app' }),
|
||||
icon: <RiFileCopy2Line />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowDuplicateModal(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
title: t('export', { ns: 'app' }),
|
||||
icon: <RiFileDownloadLine />,
|
||||
onClick: exportCheck,
|
||||
},
|
||||
]
|
||||
|
||||
const secondaryOperations: Operation[] = [
|
||||
// Import DSL (conditional)
|
||||
...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
|
||||
? [{
|
||||
id: 'import',
|
||||
title: t('importApp', { ns: 'app' }),
|
||||
icon: <RiFileUploadLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowImportDSLModal(true)
|
||||
},
|
||||
}]
|
||||
: [],
|
||||
// Divider
|
||||
{
|
||||
id: 'divider-1',
|
||||
title: '',
|
||||
icon: <></>,
|
||||
onClick: () => { /* divider has no action */ },
|
||||
type: 'divider' as const,
|
||||
},
|
||||
// Delete operation
|
||||
{
|
||||
id: 'delete',
|
||||
title: t('operation.delete', { ns: 'common' }),
|
||||
icon: <RiDeleteBinLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowConfirmDelete(true)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Keep the switch operation separate as it's not part of the main operations
|
||||
const switchOperation = (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT)
|
||||
? {
|
||||
id: 'switch',
|
||||
title: t('switch', { ns: 'app' }),
|
||||
icon: <RiExchange2Line />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowSwitchModal(true)
|
||||
},
|
||||
}
|
||||
: null
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!onlyShowDetail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isCurrentWorkspaceEditor)
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
className="block w-full"
|
||||
>
|
||||
<div className="flex flex-col gap-2 rounded-lg p-1 hover:bg-state-base-hover">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn(!expand && 'ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
</div>
|
||||
{expand && (
|
||||
<div className="ml-auto flex items-center justify-center rounded-md p-0.5">
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!expand && (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-md p-0.5">
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{expand && (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex w-full">
|
||||
<div className="truncate whitespace-nowrap text-text-secondary system-md-semibold">{appDetail.name}</div>
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-text-tertiary system-2xs-medium-uppercase">
|
||||
{appDetail.mode === AppModeEnum.ADVANCED_CHAT
|
||||
? t('types.advanced', { ns: 'app' })
|
||||
: appDetail.mode === AppModeEnum.AGENT_CHAT
|
||||
? t('types.agent', { ns: 'app' })
|
||||
: appDetail.mode === AppModeEnum.CHAT
|
||||
? t('types.chatbot', { ns: 'app' })
|
||||
: appDetail.mode === AppModeEnum.COMPLETION
|
||||
? t('types.completion', { ns: 'app' })
|
||||
: t('types.workflow', { ns: 'app' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<ContentDialog
|
||||
show={onlyShowDetail ? openState : open}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
}}
|
||||
className="absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl !p-0"
|
||||
>
|
||||
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
|
||||
<div className="flex items-center gap-3 self-stretch">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
|
||||
<div className="w-full truncate text-text-secondary system-md-semibold">{appDetail.name}</div>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('types.advanced', { ns: 'app' }) : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('types.agent', { ns: 'app' }) : appDetail.mode === AppModeEnum.CHAT ? t('types.chatbot', { ns: 'app' }) : appDetail.mode === AppModeEnum.COMPLETION ? t('types.completion', { ns: 'app' }) : t('types.workflow', { ns: 'app' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* description */}
|
||||
{appDetail.description && (
|
||||
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal break-words text-text-tertiary system-xs-regular">{appDetail.description}</div>
|
||||
)}
|
||||
{/* operations */}
|
||||
<AppOperations
|
||||
gap={4}
|
||||
primaryOperations={primaryOperations}
|
||||
secondaryOperations={secondaryOperations}
|
||||
/>
|
||||
</div>
|
||||
<CardView
|
||||
appId={appDetail.id}
|
||||
isInPanel={true}
|
||||
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
|
||||
/>
|
||||
{/* Switch operation (if available) */}
|
||||
{switchOperation && (
|
||||
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
|
||||
<Button
|
||||
size="medium"
|
||||
variant="ghost"
|
||||
className="gap-0.5"
|
||||
onClick={switchOperation.onClick}
|
||||
>
|
||||
{switchOperation.icon}
|
||||
<span className="text-text-tertiary system-sm-medium">{switchOperation.title}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ContentDialog>
|
||||
{showSwitchModal && (
|
||||
<SwitchAppModal
|
||||
inAppDetail
|
||||
show={showSwitchModal}
|
||||
appDetail={appDetail}
|
||||
onClose={() => setShowSwitchModal(false)}
|
||||
onSuccess={() => setShowSwitchModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showEditModal && (
|
||||
<CreateAppModal
|
||||
isEditModal
|
||||
appName={appDetail.name}
|
||||
appIconType={appDetail.icon_type}
|
||||
appIcon={appDetail.icon}
|
||||
appIconBackground={appDetail.icon_background}
|
||||
appIconUrl={appDetail.icon_url}
|
||||
appDescription={appDetail.description}
|
||||
appMode={appDetail.mode}
|
||||
appUseIconAsAnswerIcon={appDetail.use_icon_as_answer_icon}
|
||||
max_active_requests={appDetail.max_active_requests ?? null}
|
||||
show={showEditModal}
|
||||
onConfirm={onEdit}
|
||||
onHide={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showDuplicateModal && (
|
||||
<DuplicateAppModal
|
||||
appName={appDetail.name}
|
||||
icon_type={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
icon_background={appDetail.icon_background}
|
||||
icon_url={appDetail.icon_url}
|
||||
show={showDuplicateModal}
|
||||
onConfirm={onCopy}
|
||||
onHide={() => setShowDuplicateModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showConfirmDelete && (
|
||||
<Confirm
|
||||
title={t('deleteAppConfirmTitle', { ns: 'app' })}
|
||||
content={t('deleteAppConfirmContent', { ns: 'app' })}
|
||||
isShow={showConfirmDelete}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={() => setShowConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
{showImportDSLModal && (
|
||||
<UpdateDSLModal
|
||||
onCancel={() => setShowImportDSLModal(false)}
|
||||
onBackup={exportCheck}
|
||||
/>
|
||||
)}
|
||||
{secretEnvList.length > 0 && (
|
||||
<DSLExportConfirmModal
|
||||
envList={secretEnvList}
|
||||
onConfirm={include => onExport(include, exportSandboxed)}
|
||||
onClose={() => setSecretEnvList([])}
|
||||
/>
|
||||
)}
|
||||
{showExportWarning && (
|
||||
<Confirm
|
||||
type="info"
|
||||
isShow={showExportWarning}
|
||||
title={t('sidebar.exportWarning', { ns: 'workflow' })}
|
||||
content={t('sidebar.exportWarningDesc', { ns: 'workflow' })}
|
||||
onConfirm={handleConfirmExport}
|
||||
onCancel={() => setShowExportWarning(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppInfo)
|
||||
@@ -2,26 +2,42 @@ import { act, renderHook } from '@testing-library/react'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { useAppInfoActions } from '../use-app-info-actions'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
const mockOnPlanInfoChanged = vi.fn()
|
||||
const mockInvalidateAppList = vi.fn()
|
||||
const mockSetAppDetail = vi.fn()
|
||||
const mockUpdateAppInfo = vi.fn()
|
||||
const mockCopyApp = vi.fn()
|
||||
const mockExportAppConfig = vi.fn()
|
||||
const mockDeleteApp = vi.fn()
|
||||
const mockFetchWorkflowDraft = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
|
||||
let mockAppDetail: Record<string, unknown> | undefined = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon: '🤖',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
}
|
||||
const {
|
||||
mockAppDetail,
|
||||
mockCopyApp,
|
||||
mockDeleteApp,
|
||||
mockDownloadBlob,
|
||||
mockExportAppConfig,
|
||||
mockFetchWorkflowDraft,
|
||||
mockInvalidateAppList,
|
||||
mockNotify,
|
||||
mockOnPlanInfoChanged,
|
||||
mockReplace,
|
||||
mockSetAppDetail,
|
||||
mockUpdateAppInfo,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAppDetail: {
|
||||
current: {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
mode: 'chat',
|
||||
icon: '🤖',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
} as Record<string, unknown> | undefined,
|
||||
},
|
||||
mockCopyApp: vi.fn(),
|
||||
mockDeleteApp: vi.fn(),
|
||||
mockDownloadBlob: vi.fn(),
|
||||
mockExportAppConfig: vi.fn(),
|
||||
mockFetchWorkflowDraft: vi.fn(),
|
||||
mockInvalidateAppList: vi.fn(),
|
||||
mockNotify: vi.fn(),
|
||||
mockOnPlanInfoChanged: vi.fn(),
|
||||
mockReplace: vi.fn(),
|
||||
mockSetAppDetail: vi.fn(),
|
||||
mockUpdateAppInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
@@ -33,7 +49,7 @@ vi.mock('@/context/provider-context', () => ({
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
appDetail: mockAppDetail,
|
||||
appDetail: mockAppDetail.current,
|
||||
setAppDetail: mockSetAppDetail,
|
||||
}),
|
||||
}))
|
||||
@@ -80,7 +96,7 @@ vi.mock('@/config', () => ({
|
||||
describe('useAppInfoActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppDetail = {
|
||||
mockAppDetail.current = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
mode: AppModeEnum.CHAT,
|
||||
@@ -93,7 +109,7 @@ describe('useAppInfoActions', () => {
|
||||
describe('Initial state', () => {
|
||||
it('should return initial state correctly', () => {
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
expect(result.current.appDetail).toEqual(mockAppDetail)
|
||||
expect(result.current.appDetail).toEqual(mockAppDetail.current)
|
||||
expect(result.current.panelOpen).toBe(false)
|
||||
expect(result.current.activeModal).toBeNull()
|
||||
expect(result.current.secretEnvList).toEqual([])
|
||||
@@ -161,7 +177,7 @@ describe('useAppInfoActions', () => {
|
||||
|
||||
describe('onEdit', () => {
|
||||
it('should update app info and close modal on success', async () => {
|
||||
const updatedApp = { ...mockAppDetail, name: 'Updated' }
|
||||
const updatedApp = { ...mockAppDetail.current, name: 'Updated' }
|
||||
mockUpdateAppInfo.mockResolvedValue(updatedApp)
|
||||
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
@@ -179,7 +195,7 @@ describe('useAppInfoActions', () => {
|
||||
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalled()
|
||||
expect(mockSetAppDetail).toHaveBeenCalledWith(updatedApp)
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.editDone' })
|
||||
expect(mockNotify).toHaveBeenCalledWith('app.editDone', { type: 'success' })
|
||||
})
|
||||
|
||||
it('should notify error on edit failure', async () => {
|
||||
@@ -198,11 +214,11 @@ describe('useAppInfoActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.editFailed' })
|
||||
expect(mockNotify).toHaveBeenCalledWith('app.editFailed', { type: 'error' })
|
||||
})
|
||||
|
||||
it('should not call updateAppInfo when appDetail is undefined', async () => {
|
||||
mockAppDetail = undefined
|
||||
mockAppDetail.current = undefined
|
||||
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
|
||||
@@ -238,7 +254,7 @@ describe('useAppInfoActions', () => {
|
||||
})
|
||||
|
||||
expect(mockCopyApp).toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' })
|
||||
expect(mockNotify).toHaveBeenCalledWith('app.newApp.appCreated', { type: 'success' })
|
||||
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -256,13 +272,13 @@ describe('useAppInfoActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' })
|
||||
expect(mockNotify).toHaveBeenCalledWith('app.newApp.appCreateFailed', { type: 'error' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('onCopy - early return', () => {
|
||||
it('should not call copyApp when appDetail is undefined', async () => {
|
||||
mockAppDetail = undefined
|
||||
mockAppDetail.current = undefined
|
||||
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
|
||||
@@ -302,13 +318,13 @@ describe('useAppInfoActions', () => {
|
||||
await result.current.onExport()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' })
|
||||
expect(mockNotify).toHaveBeenCalledWith('app.exportFailed', { type: 'error' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('onExport - early return', () => {
|
||||
it('should not export when appDetail is undefined', async () => {
|
||||
mockAppDetail = undefined
|
||||
mockAppDetail.current = undefined
|
||||
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
|
||||
@@ -334,7 +350,7 @@ describe('useAppInfoActions', () => {
|
||||
})
|
||||
|
||||
it('should open export warning modal for workflow mode', async () => {
|
||||
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
|
||||
mockAppDetail.current = { ...mockAppDetail.current, mode: AppModeEnum.WORKFLOW }
|
||||
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
|
||||
@@ -346,7 +362,7 @@ describe('useAppInfoActions', () => {
|
||||
})
|
||||
|
||||
it('should open export warning modal for advanced_chat mode', async () => {
|
||||
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.ADVANCED_CHAT }
|
||||
mockAppDetail.current = { ...mockAppDetail.current, mode: AppModeEnum.ADVANCED_CHAT }
|
||||
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
|
||||
@@ -360,7 +376,7 @@ describe('useAppInfoActions', () => {
|
||||
|
||||
describe('exportCheck - early return', () => {
|
||||
it('should not do anything when appDetail is undefined', async () => {
|
||||
mockAppDetail = undefined
|
||||
mockAppDetail.current = undefined
|
||||
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
|
||||
@@ -374,7 +390,7 @@ describe('useAppInfoActions', () => {
|
||||
|
||||
describe('handleConfirmExport', () => {
|
||||
it('should export directly when no secret env variables', async () => {
|
||||
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
|
||||
mockAppDetail.current = { ...mockAppDetail.current, mode: AppModeEnum.WORKFLOW }
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
environment_variables: [{ value_type: 'string' }],
|
||||
})
|
||||
@@ -390,7 +406,7 @@ describe('useAppInfoActions', () => {
|
||||
})
|
||||
|
||||
it('should set secret env list when secret variables exist', async () => {
|
||||
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
|
||||
mockAppDetail.current = { ...mockAppDetail.current, mode: AppModeEnum.WORKFLOW }
|
||||
const secretVars = [{ value_type: 'secret', key: 'API_KEY' }]
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
environment_variables: secretVars,
|
||||
@@ -414,13 +430,13 @@ describe('useAppInfoActions', () => {
|
||||
await result.current.handleConfirmExport()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' })
|
||||
expect(mockNotify).toHaveBeenCalledWith('app.exportFailed', { type: 'error' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleConfirmExport - early return', () => {
|
||||
it('should not do anything when appDetail is undefined', async () => {
|
||||
mockAppDetail = undefined
|
||||
mockAppDetail.current = undefined
|
||||
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
|
||||
@@ -460,14 +476,14 @@ describe('useAppInfoActions', () => {
|
||||
})
|
||||
|
||||
expect(mockDeleteApp).toHaveBeenCalledWith('app-1')
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.appDeleted' })
|
||||
expect(mockNotify).toHaveBeenCalledWith('app.appDeleted', { type: 'success' })
|
||||
expect(mockInvalidateAppList).toHaveBeenCalled()
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
expect(mockSetAppDetail).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should not delete when appDetail is undefined', async () => {
|
||||
mockAppDetail = undefined
|
||||
mockAppDetail.current = undefined
|
||||
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
|
||||
@@ -479,7 +495,7 @@ describe('useAppInfoActions', () => {
|
||||
})
|
||||
|
||||
it('should notify error on delete failure', async () => {
|
||||
mockDeleteApp.mockRejectedValue({ message: 'cannot delete' })
|
||||
mockDeleteApp.mockRejectedValue(new Error('cannot delete'))
|
||||
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
|
||||
@@ -487,10 +503,7 @@ describe('useAppInfoActions', () => {
|
||||
await result.current.onConfirmDelete()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.stringContaining('app.appDeleteFailed'),
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith('app.appDeleteFailed: cannot delete', { type: 'error' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ChunkingMode,
|
||||
DatasetPermission,
|
||||
DataSourceType,
|
||||
} from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Dropdown from '../dropdown'
|
||||
|
||||
let mockDataset: DataSet
|
||||
let mockIsDatasetOperator = false
|
||||
const mockReplace = vi.fn()
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
const mockInvalidDatasetDetail = vi.fn()
|
||||
const mockExportPipeline = vi.fn()
|
||||
const mockCheckIsUsedInApp = vi.fn()
|
||||
const mockDeleteDataset = vi.fn()
|
||||
const mockToast = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
|
||||
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset Name',
|
||||
indexing_status: 'completed',
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_type: 'emoji',
|
||||
icon_url: '',
|
||||
},
|
||||
description: 'Dataset description',
|
||||
permission: DatasetPermission.onlyMe,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
|
||||
created_by: 'user-1',
|
||||
updated_by: 'user-1',
|
||||
updated_at: 1690000000,
|
||||
app_count: 0,
|
||||
doc_form: ChunkingMode.text,
|
||||
document_count: 1,
|
||||
total_document_count: 1,
|
||||
word_count: 1000,
|
||||
provider: 'internal',
|
||||
embedding_model: 'text-embedding-3',
|
||||
embedding_model_provider: 'openai',
|
||||
embedding_available: true,
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 5,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
retrieval_model: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 5,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
tags: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 0,
|
||||
score_threshold: 0,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
built_in_field_enabled: false,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
enable_api: false,
|
||||
is_multimodal: false,
|
||||
pipeline_id: 'pipeline-1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) =>
|
||||
selector({ isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
datasetDetailQueryKeyPrefix: ['dataset', 'detail'],
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-base', () => ({
|
||||
useInvalid: () => mockInvalidDatasetDetail,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({
|
||||
mutateAsync: mockExportPipeline,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args),
|
||||
deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: (...args: unknown[]) => mockToast(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/rename-modal', () => ({
|
||||
default: ({
|
||||
show,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) => (show
|
||||
? (
|
||||
<div data-testid="rename-modal">
|
||||
<button type="button" onClick={onSuccess}>rename-success</button>
|
||||
<button type="button" onClick={onClose}>rename-close</button>
|
||||
</div>
|
||||
)
|
||||
: null),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({
|
||||
isShow,
|
||||
content,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
isShow: boolean
|
||||
content: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<span>{content}</span>
|
||||
<button type="button" onClick={onConfirm}>confirm</button>
|
||||
<button type="button" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('Dropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDataset = createDataset()
|
||||
mockIsDatasetOperator = false
|
||||
mockExportPipeline.mockResolvedValue({ data: 'pipeline-yaml' })
|
||||
mockDeleteDataset.mockResolvedValue({})
|
||||
mockCheckIsUsedInApp.mockResolvedValue({ is_using: true })
|
||||
})
|
||||
|
||||
it('should export the pipeline configuration and download the exported file', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportPipeline).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-1',
|
||||
include: false,
|
||||
})
|
||||
})
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: 'Dataset Name.pipeline',
|
||||
})
|
||||
})
|
||||
|
||||
it('should confirm deletion with usage-aware copy and delete the dataset after confirmation', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCheckIsUsedInApp).toHaveBeenCalledWith('dataset-1')
|
||||
})
|
||||
expect(screen.getByText('dataset.datasetUsedByApp')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
|
||||
})
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1)
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
|
||||
it('should open the rename modal and hide delete actions for dataset operators', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockIsDatasetOperator = true
|
||||
|
||||
render(<Dropdown expand={false} />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('rename-success'))
|
||||
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidDatasetDetail).toHaveBeenCalledTimes(1)
|
||||
|
||||
await user.click(screen.getByText('rename-close'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('rename-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show the standard delete confirmation copy and close the dialog on cancel', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCheckIsUsedInApp.mockResolvedValue({ is_using: false })
|
||||
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dataset.deleteDatasetConfirmContent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('cancel'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show an error toast when dataset usage detection fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const json = vi.fn().mockResolvedValue({ message: 'Dataset still linked' })
|
||||
mockCheckIsUsedInApp.mockRejectedValue({ json })
|
||||
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(json).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockToast).toHaveBeenCalledWith('Dataset still linked', { type: 'error' })
|
||||
})
|
||||
|
||||
it('should not export anything when the dataset has no pipeline id', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDataset = createDataset({ pipeline_id: undefined })
|
||||
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
|
||||
|
||||
expect(mockExportPipeline).not.toHaveBeenCalled()
|
||||
expect(mockDownloadBlob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show an error toast when pipeline export fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportPipeline.mockRejectedValue(new Error('export failed'))
|
||||
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast).toHaveBeenCalledWith('app.exportFailed', { type: 'error' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import MenuItem from '../menu-item'
|
||||
|
||||
const TestIcon = ({ className }: { className?: string }) => (
|
||||
<span data-testid="menu-item-icon" className={className} />
|
||||
)
|
||||
|
||||
describe('MenuItem', () => {
|
||||
it('should stop propagation and invoke the click handler', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
const parentClick = vi.fn()
|
||||
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<MenuItem name="Edit" Icon={TestIcon} handleClick={handleClick} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Edit'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {
|
||||
ChunkingMode,
|
||||
DatasetPermission,
|
||||
DataSourceType,
|
||||
} from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Menu from '../menu'
|
||||
|
||||
let mockDataset: Partial<DataSet>
|
||||
|
||||
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset Name',
|
||||
indexing_status: 'completed',
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_type: 'emoji',
|
||||
icon_url: '',
|
||||
},
|
||||
description: 'Dataset description',
|
||||
permission: DatasetPermission.onlyMe,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
|
||||
created_by: 'user-1',
|
||||
updated_by: 'user-1',
|
||||
updated_at: 1690000000,
|
||||
app_count: 0,
|
||||
doc_form: ChunkingMode.text,
|
||||
document_count: 1,
|
||||
total_document_count: 1,
|
||||
word_count: 1000,
|
||||
provider: 'internal',
|
||||
embedding_model: 'text-embedding-3',
|
||||
embedding_model_provider: 'openai',
|
||||
embedding_available: true,
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 5,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
retrieval_model: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 5,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
tags: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 0,
|
||||
score_threshold: 0,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
built_in_field_enabled: false,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
enable_api: false,
|
||||
is_multimodal: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: (selector: (state: { dataset?: Partial<DataSet> }) => unknown) =>
|
||||
selector({ dataset: mockDataset }),
|
||||
}))
|
||||
|
||||
describe('Menu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDataset = createDataset()
|
||||
})
|
||||
|
||||
it('should render edit, export, and delete actions for rag pipeline datasets', () => {
|
||||
render(
|
||||
<Menu
|
||||
showDelete
|
||||
openRenameModal={vi.fn()}
|
||||
handleExportPipeline={vi.fn()}
|
||||
detectIsUsedByApp={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.operations.exportPipeline')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide export action when the dataset is not a rag pipeline dataset', () => {
|
||||
mockDataset = createDataset({ runtime_mode: 'general' })
|
||||
|
||||
render(
|
||||
<Menu
|
||||
showDelete
|
||||
openRenameModal={vi.fn()}
|
||||
handleExportPipeline={vi.fn()}
|
||||
detectIsUsedByApp={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('datasetPipeline.operations.exportPipeline')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should invoke menu callbacks when actions are clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const openRenameModal = vi.fn()
|
||||
const handleExportPipeline = vi.fn()
|
||||
const detectIsUsedByApp = vi.fn()
|
||||
|
||||
render(
|
||||
<Menu
|
||||
showDelete
|
||||
openRenameModal={openRenameModal}
|
||||
handleExportPipeline={handleExportPipeline}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
expect(openRenameModal).toHaveBeenCalledTimes(1)
|
||||
expect(handleExportPipeline).toHaveBeenCalledTimes(1)
|
||||
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -220,6 +220,7 @@ export type WorkflowToolProviderOutputSchema = {
|
||||
|
||||
export type WorkflowToolProviderRequest = {
|
||||
name: string
|
||||
label: string
|
||||
icon: Emoji
|
||||
description: string
|
||||
parameters: WorkflowToolProviderParameter[]
|
||||
|
||||
229
web/app/components/tools/workflow-tool/__tests__/index.spec.tsx
Normal file
229
web/app/components/tools/workflow-tool/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { WorkflowToolModalPayload } from '../index'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import WorkflowToolAsModal from '../index'
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: (message: string) => mockToastNotify({ type: 'error', message }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/drawer-plus', () => ({
|
||||
default: ({ body }: { body: React.ReactNode }) => (
|
||||
<div data-testid="drawer">{body}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/emoji-picker', () => ({
|
||||
default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => (
|
||||
<div data-testid="emoji-picker">
|
||||
<button type="button" onClick={() => onSelect('🚀', '#f0f0f0')}>select emoji</button>
|
||||
<button type="button" onClick={onClose}>close emoji</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ onClick, icon, background }: { onClick?: () => void, icon: string, background: string }) => (
|
||||
<button type="button" data-testid="app-icon" data-icon={icon} data-background={background} onClick={onClick}>
|
||||
icon
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/labels/selector', () => ({
|
||||
default: ({ value, onChange }: { value: string[], onChange: (value: string[]) => void }) => (
|
||||
<div>
|
||||
<span data-testid="label-values">{value.join(',')}</span>
|
||||
<button type="button" onClick={() => onChange([...value, 'new-label'])}>add label</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../method-selector', () => ({
|
||||
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
|
||||
<select aria-label="parameter method" value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="llm">llm</option>
|
||||
<option value="form">form</option>
|
||||
</select>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../confirm-modal', () => ({
|
||||
default: ({ show, onClose, onConfirm }: { show: boolean, onClose: () => void, onConfirm: () => void }) => (
|
||||
show
|
||||
? (
|
||||
<div data-testid="confirm-modal">
|
||||
<button type="button" onClick={onConfirm}>confirm save</button>
|
||||
<button type="button" onClick={onClose}>cancel save</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
const createPayload = (overrides: Partial<WorkflowToolModalPayload> = {}): WorkflowToolModalPayload => ({
|
||||
icon: {
|
||||
content: '🔧',
|
||||
background: '#ffffff',
|
||||
},
|
||||
label: 'Test Tool',
|
||||
name: 'test_tool',
|
||||
description: 'Test description',
|
||||
parameters: [
|
||||
{
|
||||
name: 'param1',
|
||||
description: 'Parameter 1',
|
||||
form: 'llm',
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
outputParameters: [
|
||||
{
|
||||
name: 'text',
|
||||
description: 'Duplicate output',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'result',
|
||||
description: 'Result output',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
labels: ['label1'],
|
||||
privacy_policy: 'https://example.com/privacy',
|
||||
workflow_app_id: 'workflow-app-123',
|
||||
workflow_tool_id: 'workflow-tool-456',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('WorkflowToolAsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should submit create payload with updated form state in add mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCreate = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowToolAsModal
|
||||
isAdd
|
||||
payload={createPayload()}
|
||||
onHide={vi.fn()}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
)
|
||||
|
||||
const titleInput = screen.getByDisplayValue('Test Tool')
|
||||
const nameInput = screen.getByDisplayValue('test_tool')
|
||||
const descriptionInput = screen.getByDisplayValue('Test description')
|
||||
const parameterDescriptionInput = screen.getByDisplayValue('Parameter 1')
|
||||
|
||||
await user.click(screen.getByTestId('app-icon'))
|
||||
await user.click(screen.getByText('select emoji'))
|
||||
await user.clear(titleInput)
|
||||
await user.type(titleInput, 'Updated Tool')
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'updated_tool')
|
||||
await user.clear(descriptionInput)
|
||||
await user.type(descriptionInput, 'Updated description')
|
||||
await user.selectOptions(screen.getByLabelText('parameter method'), 'form')
|
||||
await user.clear(parameterDescriptionInput)
|
||||
await user.type(parameterDescriptionInput, 'Updated parameter')
|
||||
await user.click(screen.getByText('add label'))
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith({
|
||||
description: 'Updated description',
|
||||
icon: {
|
||||
content: '🚀',
|
||||
background: '#f0f0f0',
|
||||
},
|
||||
label: 'Updated Tool',
|
||||
labels: ['label1', 'new-label'],
|
||||
name: 'updated_tool',
|
||||
parameters: [
|
||||
{
|
||||
name: 'param1',
|
||||
description: 'Updated parameter',
|
||||
form: 'form',
|
||||
},
|
||||
],
|
||||
privacy_policy: 'https://example.com/privacy',
|
||||
workflow_app_id: 'workflow-app-123',
|
||||
})
|
||||
})
|
||||
|
||||
it('should block submission and show an error when the tool-call name is invalid', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCreate = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowToolAsModal
|
||||
isAdd
|
||||
payload={createPayload()}
|
||||
onHide={vi.fn()}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
)
|
||||
|
||||
const nameInput = screen.getByDisplayValue('test_tool')
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'bad-name')
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(onCreate).not.toHaveBeenCalled()
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'tools.createTool.nameForToolCalltools.createTool.nameForToolCallTip',
|
||||
})
|
||||
})
|
||||
|
||||
it('should require confirmation before saving in edit mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowToolAsModal
|
||||
payload={createPayload()}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('confirm save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
description: 'Test description',
|
||||
icon: {
|
||||
content: '🔧',
|
||||
background: '#ffffff',
|
||||
},
|
||||
label: 'Test Tool',
|
||||
labels: ['label1'],
|
||||
name: 'test_tool',
|
||||
parameters: [
|
||||
{
|
||||
name: 'param1',
|
||||
description: 'Parameter 1',
|
||||
form: 'llm',
|
||||
},
|
||||
],
|
||||
privacy_policy: 'https://example.com/privacy',
|
||||
workflow_tool_id: 'workflow-tool-456',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { WorkflowToolModalPayload } from '../index'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useWorkflowToolForm } from '../use-workflow-tool-form'
|
||||
|
||||
const mockToastError = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const createPayload = (): WorkflowToolModalPayload => ({
|
||||
icon: {
|
||||
content: '🔧',
|
||||
background: '#ffffff',
|
||||
},
|
||||
label: 'Test Tool',
|
||||
name: 'test_tool',
|
||||
description: 'Test description',
|
||||
parameters: [
|
||||
{
|
||||
name: 'param1',
|
||||
description: 'Parameter 1',
|
||||
form: 'llm',
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
outputParameters: [
|
||||
{
|
||||
name: 'result',
|
||||
description: 'Result output',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
labels: ['label1'],
|
||||
privacy_policy: 'https://example.com/privacy',
|
||||
workflow_app_id: 'workflow-app-123',
|
||||
workflow_tool_id: 'workflow-tool-456',
|
||||
})
|
||||
|
||||
describe('useWorkflowToolForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should submit create payload immediately in add mode', () => {
|
||||
const onCreate = vi.fn()
|
||||
const { result } = renderHook(() => useWorkflowToolForm({
|
||||
isAdd: true,
|
||||
onCreate,
|
||||
payload: createPayload(),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handlePrimaryAction()
|
||||
})
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith({
|
||||
description: 'Test description',
|
||||
icon: {
|
||||
content: '🔧',
|
||||
background: '#ffffff',
|
||||
},
|
||||
label: 'Test Tool',
|
||||
labels: ['label1'],
|
||||
name: 'test_tool',
|
||||
parameters: [
|
||||
{
|
||||
name: 'param1',
|
||||
description: 'Parameter 1',
|
||||
form: 'llm',
|
||||
},
|
||||
],
|
||||
privacy_policy: 'https://example.com/privacy',
|
||||
workflow_app_id: 'workflow-app-123',
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the confirmation modal in edit mode before saving', () => {
|
||||
const { result } = renderHook(() => useWorkflowToolForm({
|
||||
onSave: vi.fn(),
|
||||
payload: createPayload(),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handlePrimaryAction()
|
||||
})
|
||||
|
||||
expect(result.current.showConfirmModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should report validation errors when the tool-call name is invalid', () => {
|
||||
const onCreate = vi.fn()
|
||||
const { result } = renderHook(() => useWorkflowToolForm({
|
||||
isAdd: true,
|
||||
onCreate,
|
||||
payload: createPayload(),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setName('bad-name')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(onCreate).not.toHaveBeenCalled()
|
||||
expect(mockToastError).toHaveBeenCalledWith('tools.createTool.nameForToolCalltools.createTool.nameForToolCallTip')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import WorkflowToolForm from '../workflow-tool-form'
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ onClick }: { onClick?: () => void }) => (
|
||||
<button type="button" data-testid="app-icon" onClick={onClick}>icon</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/labels/selector', () => ({
|
||||
default: ({ value, onChange }: { value: string[], onChange: (value: string[]) => void }) => (
|
||||
<button type="button" onClick={() => onChange([...value, 'new-label'])}>labels</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../method-selector', () => ({
|
||||
default: ({ onChange }: { onChange: (value: string) => void }) => (
|
||||
<button type="button" onClick={() => onChange('form')}>change method</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ render }: { render?: React.ReactNode }) => <>{render}</>,
|
||||
TooltipContent: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
describe('WorkflowToolForm', () => {
|
||||
it('should wire form callbacks from the rendered controls', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onTitleChange = vi.fn()
|
||||
const onNameChange = vi.fn()
|
||||
const onDescriptionChange = vi.fn()
|
||||
const onParameterChange = vi.fn()
|
||||
const onLabelChange = vi.fn()
|
||||
const onPrivacyPolicyChange = vi.fn()
|
||||
const onEmojiClick = vi.fn()
|
||||
const onHide = vi.fn()
|
||||
const onPrimaryAction = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowToolForm
|
||||
description="Test description"
|
||||
emoji={{ content: '🔧', background: '#ffffff' }}
|
||||
isNameValid={true}
|
||||
labels={['label1']}
|
||||
name="test_tool"
|
||||
onDescriptionChange={onDescriptionChange}
|
||||
onEmojiClick={onEmojiClick}
|
||||
onHide={onHide}
|
||||
onLabelChange={onLabelChange}
|
||||
onNameChange={onNameChange}
|
||||
onParameterChange={onParameterChange}
|
||||
onPrimaryAction={onPrimaryAction}
|
||||
onPrivacyPolicyChange={onPrivacyPolicyChange}
|
||||
onRemove={onRemove}
|
||||
onTitleChange={onTitleChange}
|
||||
outputParameters={[
|
||||
{
|
||||
name: 'text',
|
||||
description: 'Duplicate output',
|
||||
type: VarType.string,
|
||||
},
|
||||
]}
|
||||
parameters={[
|
||||
{
|
||||
name: 'param1',
|
||||
description: 'Parameter 1',
|
||||
form: 'llm',
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
]}
|
||||
privacyPolicy="https://example.com/privacy"
|
||||
title="Test Tool"
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('app-icon'))
|
||||
await user.type(screen.getByDisplayValue('Test Tool'), '!')
|
||||
await user.type(screen.getByDisplayValue('test_tool'), '!')
|
||||
await user.type(screen.getByDisplayValue('Test description'), '!')
|
||||
await user.click(screen.getByText('change method'))
|
||||
await user.type(screen.getByDisplayValue('Parameter 1'), '!')
|
||||
await user.click(screen.getByText('labels'))
|
||||
await user.type(screen.getByDisplayValue('https://example.com/privacy'), '/policy')
|
||||
await user.click(screen.getByText('common.operation.cancel'))
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
expect(onEmojiClick).toHaveBeenCalledTimes(1)
|
||||
expect(onTitleChange).toHaveBeenCalled()
|
||||
expect(onNameChange).toHaveBeenCalled()
|
||||
expect(onDescriptionChange).toHaveBeenCalled()
|
||||
expect(onParameterChange).toHaveBeenCalled()
|
||||
expect(onLabelChange).toHaveBeenCalledWith(['label1', 'new-label'])
|
||||
expect(onPrivacyPolicyChange).toHaveBeenCalled()
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
expect(onPrimaryAction).toHaveBeenCalledTimes(1)
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('tools.createTool.toolOutput.reservedParameterDuplicateTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image parameters without a method selector in add mode', () => {
|
||||
render(
|
||||
<WorkflowToolForm
|
||||
description=""
|
||||
emoji={{ content: '🧪', background: '#000000' }}
|
||||
isAdd
|
||||
isNameValid={false}
|
||||
labels={[]}
|
||||
name="image_tool"
|
||||
onDescriptionChange={vi.fn()}
|
||||
onEmojiClick={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
onLabelChange={vi.fn()}
|
||||
onNameChange={vi.fn()}
|
||||
onParameterChange={vi.fn()}
|
||||
onPrimaryAction={vi.fn()}
|
||||
onPrivacyPolicyChange={vi.fn()}
|
||||
onTitleChange={vi.fn()}
|
||||
outputParameters={[
|
||||
{
|
||||
name: 'custom_output',
|
||||
description: 'Custom output',
|
||||
type: VarType.string,
|
||||
},
|
||||
]}
|
||||
parameters={[
|
||||
{
|
||||
name: '__image',
|
||||
description: 'Image input',
|
||||
form: 'llm',
|
||||
required: false,
|
||||
type: 'file',
|
||||
},
|
||||
]}
|
||||
privacyPolicy=""
|
||||
title="Image Tool"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('tools.createTool.nameForToolCallTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.createTool.toolInput.methodParameter')).toBeInTheDocument()
|
||||
expect(screen.queryByText('change method')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,55 +1,17 @@
|
||||
'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 { WorkflowToolModalProps } from './types'
|
||||
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'
|
||||
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 Tooltip from '@/app/components/base/tooltip'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
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 { useWorkflowToolForm } from './use-workflow-tool-form'
|
||||
import WorkflowToolForm from './workflow-tool-form'
|
||||
|
||||
export type WorkflowToolModalPayload = {
|
||||
icon: Emoji
|
||||
label: string
|
||||
name: string
|
||||
description: string
|
||||
parameters: WorkflowToolProviderParameter[]
|
||||
outputParameters: WorkflowToolProviderOutputParameter[]
|
||||
labels: string[]
|
||||
privacy_policy: string
|
||||
tool?: {
|
||||
output_schema?: WorkflowToolProviderOutputSchema
|
||||
}
|
||||
workflow_tool_id?: string
|
||||
workflow_app_id?: string
|
||||
}
|
||||
export type { WorkflowToolModalPayload } from './types'
|
||||
|
||||
type Props = {
|
||||
isAdd?: boolean
|
||||
payload: WorkflowToolModalPayload
|
||||
onHide: () => void
|
||||
onRemove?: () => void
|
||||
onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
|
||||
onSave?: (payload: WorkflowToolProviderRequest & Partial<{
|
||||
workflow_app_id: string
|
||||
workflow_tool_id: string
|
||||
}>) => void
|
||||
}
|
||||
// Add and Edit
|
||||
const WorkflowToolAsModal: FC<Props> = ({
|
||||
const WorkflowToolAsModal: FC<WorkflowToolModalProps> = ({
|
||||
isAdd,
|
||||
payload,
|
||||
onHide,
|
||||
@@ -58,107 +20,35 @@ const WorkflowToolAsModal: FC<Props> = ({
|
||||
onCreate,
|
||||
}) => {
|
||||
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,
|
||||
},
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
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.error(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!,
|
||||
})
|
||||
}
|
||||
}
|
||||
const {
|
||||
description,
|
||||
emoji,
|
||||
handleConfirm,
|
||||
handleParameterChange,
|
||||
handlePrimaryAction,
|
||||
isNameCurrentlyValid,
|
||||
label,
|
||||
labels,
|
||||
name,
|
||||
outputParameters,
|
||||
parameters,
|
||||
privacyPolicy,
|
||||
setDescription,
|
||||
setEmoji,
|
||||
setLabel,
|
||||
setLabels,
|
||||
setName,
|
||||
setPrivacyPolicy,
|
||||
setShowConfirmModal,
|
||||
setShowEmojiPicker,
|
||||
showConfirmModal,
|
||||
showEmojiPicker,
|
||||
} = useWorkflowToolForm({
|
||||
isAdd,
|
||||
onCreate,
|
||||
onSave,
|
||||
payload,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -171,195 +61,28 @@ const WorkflowToolAsModal: FC<Props> = ({
|
||||
height="calc(100vh - 16px)"
|
||||
headerClassName="!border-b-divider"
|
||||
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="py-2 text-text-primary system-sm-medium">
|
||||
{t('createTool.name', { ns: 'tools' })}
|
||||
{' '}
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</div>
|
||||
<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} />
|
||||
<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="flex items-center py-2 text-text-primary system-sm-medium">
|
||||
{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>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
className="h-10"
|
||||
placeholder={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })!}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
{!isNameValid(name) && (
|
||||
<div className="text-xs leading-[18px] text-red-500">{t('createTool.nameForToolCallTip', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* description */}
|
||||
<div>
|
||||
<div className="py-2 text-text-primary system-sm-medium">{t('createTool.description', { ns: 'tools' })}</div>
|
||||
<Textarea
|
||||
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Tool Input */}
|
||||
<div>
|
||||
<div className="py-2 text-text-primary system-sm-medium">{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="py-2 text-text-primary system-sm-medium">{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>
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div className="py-2 text-text-primary system-sm-medium">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
|
||||
<LabelSelector value={labels} onChange={handleLabelSelect} />
|
||||
</div>
|
||||
{/* Privacy Policy */}
|
||||
<div>
|
||||
<div className="py-2 text-text-primary system-sm-medium">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
|
||||
<Input
|
||||
className="h-10"
|
||||
value={privacyPolicy}
|
||||
onChange={e => 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>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowToolForm
|
||||
description={description}
|
||||
emoji={emoji}
|
||||
isAdd={isAdd}
|
||||
isNameValid={isNameCurrentlyValid}
|
||||
labels={labels}
|
||||
name={name}
|
||||
onDescriptionChange={setDescription}
|
||||
onEmojiClick={() => setShowEmojiPicker(true)}
|
||||
onHide={onHide}
|
||||
onLabelChange={setLabels}
|
||||
onNameChange={setName}
|
||||
onParameterChange={handleParameterChange}
|
||||
onPrimaryAction={handlePrimaryAction}
|
||||
onPrivacyPolicyChange={setPrivacyPolicy}
|
||||
onRemove={onRemove}
|
||||
onTitleChange={setLabel}
|
||||
outputParameters={outputParameters}
|
||||
parameters={parameters}
|
||||
privacyPolicy={privacyPolicy}
|
||||
title={label}
|
||||
/>
|
||||
)}
|
||||
isShowMask={true}
|
||||
clickOutsideNotOpen={true}
|
||||
@@ -375,15 +98,14 @@ const WorkflowToolAsModal: FC<Props> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showModal && (
|
||||
{showConfirmModal && (
|
||||
<ConfirmModal
|
||||
show={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onConfirm={onConfirm}
|
||||
show={showConfirmModal}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(WorkflowToolAsModal)
|
||||
|
||||
29
web/app/components/tools/workflow-tool/types.ts
Normal file
29
web/app/components/tools/workflow-tool/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
|
||||
|
||||
export type WorkflowToolModalPayload = {
|
||||
icon: Emoji
|
||||
label: string
|
||||
name: string
|
||||
description: string
|
||||
parameters: WorkflowToolProviderParameter[]
|
||||
outputParameters: WorkflowToolProviderOutputParameter[]
|
||||
labels: string[]
|
||||
privacy_policy: string
|
||||
tool?: {
|
||||
output_schema?: WorkflowToolProviderOutputSchema
|
||||
}
|
||||
workflow_tool_id?: string
|
||||
workflow_app_id?: string
|
||||
}
|
||||
|
||||
export type WorkflowToolModalProps = {
|
||||
isAdd?: boolean
|
||||
payload: WorkflowToolModalPayload
|
||||
onHide: () => void
|
||||
onRemove?: () => void
|
||||
onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
|
||||
onSave?: (payload: WorkflowToolProviderRequest & Partial<{
|
||||
workflow_app_id: string
|
||||
workflow_tool_id: string
|
||||
}>) => void
|
||||
}
|
||||
131
web/app/components/tools/workflow-tool/use-workflow-tool-form.ts
Normal file
131
web/app/components/tools/workflow-tool/use-workflow-tool-form.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { Emoji, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
|
||||
import type { WorkflowToolModalProps } from './types'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { buildWorkflowOutputParameters } from './utils'
|
||||
|
||||
export const isWorkflowToolNameValid = (name: string) => {
|
||||
if (name === '')
|
||||
return true
|
||||
|
||||
return /^\w+$/.test(name)
|
||||
}
|
||||
|
||||
type UseWorkflowToolFormOptions = Pick<WorkflowToolModalProps, 'isAdd' | 'onCreate' | 'onSave' | 'payload'>
|
||||
|
||||
export const useWorkflowToolForm = ({
|
||||
isAdd,
|
||||
onCreate,
|
||||
onSave,
|
||||
payload,
|
||||
}: UseWorkflowToolFormOptions) => {
|
||||
const { t } = useTranslation()
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
||||
const [emoji, setEmoji] = useState<Emoji>(payload.icon)
|
||||
const [label, setLabel] = useState(payload.label)
|
||||
const [name, setName] = useState(payload.name)
|
||||
const [description, setDescription] = useState(payload.description)
|
||||
const [parameters, setParameters] = useState<WorkflowToolProviderParameter[]>(payload.parameters)
|
||||
const [labels, setLabels] = useState<string[]>(payload.labels)
|
||||
const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy)
|
||||
|
||||
const outputParameters = useMemo(
|
||||
() => buildWorkflowOutputParameters(payload.outputParameters, payload.tool?.output_schema),
|
||||
[payload.outputParameters, payload.tool?.output_schema],
|
||||
)
|
||||
const isNameCurrentlyValid = isWorkflowToolNameValid(name)
|
||||
|
||||
const handleParameterChange = useCallback((key: 'description' | 'form', value: string, index: number) => {
|
||||
setParameters(current => produce(current, (draft) => {
|
||||
const parameter = draft[index]
|
||||
if (!parameter)
|
||||
return
|
||||
|
||||
if (key === 'description')
|
||||
parameter.description = value
|
||||
else
|
||||
parameter.form = value
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
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 (!isWorkflowToolNameValid(name))
|
||||
errorMessage = t('createTool.nameForToolCall', { ns: 'tools' }) + t('createTool.nameForToolCallTip', { ns: 'tools' })
|
||||
|
||||
if (errorMessage) {
|
||||
toast.error(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const requestParams: WorkflowToolProviderRequest = {
|
||||
name,
|
||||
description,
|
||||
icon: emoji,
|
||||
label,
|
||||
parameters: parameters.map(item => ({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
form: item.form,
|
||||
})),
|
||||
labels,
|
||||
privacy_policy: privacyPolicy,
|
||||
}
|
||||
|
||||
if (isAdd) {
|
||||
onCreate?.({
|
||||
...requestParams,
|
||||
workflow_app_id: payload.workflow_app_id!,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onSave?.({
|
||||
...requestParams,
|
||||
workflow_tool_id: payload.workflow_tool_id!,
|
||||
})
|
||||
}, [description, emoji, isAdd, label, labels, name, onCreate, onSave, parameters, payload.workflow_app_id, payload.workflow_tool_id, privacyPolicy, t])
|
||||
|
||||
const handlePrimaryAction = useCallback(() => {
|
||||
if (isAdd) {
|
||||
handleConfirm()
|
||||
return
|
||||
}
|
||||
|
||||
setShowConfirmModal(true)
|
||||
}, [handleConfirm, isAdd])
|
||||
|
||||
return {
|
||||
description,
|
||||
emoji,
|
||||
handleConfirm,
|
||||
handleParameterChange,
|
||||
handlePrimaryAction,
|
||||
isNameCurrentlyValid,
|
||||
label,
|
||||
labels,
|
||||
name,
|
||||
outputParameters,
|
||||
parameters,
|
||||
privacyPolicy,
|
||||
setDescription,
|
||||
setEmoji,
|
||||
setLabel,
|
||||
setLabels,
|
||||
setName,
|
||||
setPrivacyPolicy,
|
||||
setShowConfirmModal,
|
||||
setShowEmojiPicker,
|
||||
showConfirmModal,
|
||||
showEmojiPicker,
|
||||
}
|
||||
}
|
||||
270
web/app/components/tools/workflow-tool/workflow-tool-form.tsx
Normal file
270
web/app/components/tools/workflow-tool/workflow-tool-form.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
'use client'
|
||||
|
||||
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter } from '../types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import LabelSelector from '@/app/components/tools/labels/selector'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import MethodSelector from './method-selector'
|
||||
|
||||
type WorkflowToolFormProps = {
|
||||
description: string
|
||||
emoji: Emoji
|
||||
isAdd?: boolean
|
||||
isNameValid: boolean
|
||||
labels: string[]
|
||||
name: string
|
||||
onDescriptionChange: (value: string) => void
|
||||
onEmojiClick: () => void
|
||||
onHide: () => void
|
||||
onLabelChange: (value: string[]) => void
|
||||
onNameChange: (value: string) => void
|
||||
onParameterChange: (key: 'description' | 'form', value: string, index: number) => void
|
||||
onPrimaryAction: () => void
|
||||
onPrivacyPolicyChange: (value: string) => void
|
||||
onRemove?: () => void
|
||||
onTitleChange: (value: string) => void
|
||||
outputParameters: WorkflowToolProviderOutputParameter[]
|
||||
parameters: WorkflowToolProviderParameter[]
|
||||
privacyPolicy: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const WorkflowToolForm = ({
|
||||
description,
|
||||
emoji,
|
||||
isAdd,
|
||||
isNameValid,
|
||||
labels,
|
||||
name,
|
||||
onDescriptionChange,
|
||||
onEmojiClick,
|
||||
onHide,
|
||||
onLabelChange,
|
||||
onNameChange,
|
||||
onParameterChange,
|
||||
onPrimaryAction,
|
||||
onPrivacyPolicyChange,
|
||||
onRemove,
|
||||
onTitleChange,
|
||||
outputParameters,
|
||||
parameters,
|
||||
privacyPolicy,
|
||||
title,
|
||||
}: WorkflowToolFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
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])
|
||||
const isOutputParameterReserved = (value: string) => reservedOutputParameters.some(item => item.name === value)
|
||||
const outputParameterRows = useMemo(() => {
|
||||
const seenKeys = new Map<string, number>()
|
||||
|
||||
return [...reservedOutputParameters, ...outputParameters].map((item) => {
|
||||
const baseKey = [item.reserved ? 'reserved' : 'output', item.name, item.type ?? '', item.description].join(':')
|
||||
const occurrence = seenKeys.get(baseKey) ?? 0
|
||||
seenKeys.set(baseKey, occurrence + 1)
|
||||
|
||||
return {
|
||||
item,
|
||||
key: occurrence === 0 ? baseKey : `${baseKey}:${occurrence}`,
|
||||
}
|
||||
})
|
||||
}, [outputParameters, reservedOutputParameters])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
|
||||
<div>
|
||||
<div className="py-2 text-text-primary system-sm-medium">
|
||||
{t('createTool.name', { ns: 'tools' })}
|
||||
{' '}
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<AppIcon size="large" onClick={onEmojiClick} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} />
|
||||
<Input
|
||||
className="h-10 grow"
|
||||
placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
|
||||
value={title}
|
||||
onChange={e => onTitleChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center py-2 text-text-primary system-sm-medium">
|
||||
{t('createTool.nameForToolCall', { ns: 'tools' })}
|
||||
{' '}
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span aria-hidden className="i-ri-question-line ml-1 h-3.5 w-3.5 shrink-0 text-text-quaternary hover:text-text-tertiary" />} />
|
||||
<TooltipContent>
|
||||
<div className="w-[180px]">
|
||||
{t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
className="h-10"
|
||||
placeholder={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })!}
|
||||
value={name}
|
||||
onChange={e => onNameChange(e.target.value)}
|
||||
/>
|
||||
{!isNameValid && (
|
||||
<div className="text-xs leading-[18px] text-red-500">{t('createTool.nameForToolCallTip', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="py-2 text-text-primary system-sm-medium">{t('createTool.description', { ns: 'tools' })}</div>
|
||||
<Textarea
|
||||
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
|
||||
value={description}
|
||||
onChange={e => onDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="py-2 text-text-primary system-sm-medium">{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={item.name} 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>
|
||||
)
|
||||
: (
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="py-2 text-text-primary system-sm-medium">{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>
|
||||
{outputParameterRows.map(({ item, key }) => (
|
||||
<tr key={key} 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>
|
||||
<TooltipTrigger render={<span aria-hidden className="i-ri-error-warning-line h-3 w-3 text-text-warning-secondary" />} />
|
||||
<TooltipContent>
|
||||
<div className="w-[180px]">
|
||||
{t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="py-2 text-text-primary system-sm-medium">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
|
||||
<LabelSelector value={labels} onChange={onLabelChange} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="py-2 text-text-primary system-sm-medium">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
|
||||
<Input
|
||||
className="h-10"
|
||||
value={privacyPolicy}
|
||||
onChange={e => onPrivacyPolicyChange(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={onPrimaryAction}>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowToolForm
|
||||
Reference in New Issue
Block a user