mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 10:53:06 +08:00
refactor(web): streamline skill dependency fetching and batch upload flows
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useNodeSkills } from '../use-node-skills'
|
||||
|
||||
type MockNode = {
|
||||
id: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
nodeSkills: vi.fn(),
|
||||
nodeSkillsQueryKey: vi.fn((_input: unknown) => ['console', 'workflowDraft', 'nodeSkills']),
|
||||
store: {
|
||||
getState: vi.fn(),
|
||||
},
|
||||
nodes: [] as MockNode[],
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
workflowDraft: {
|
||||
nodeSkills: (input: unknown) => mocks.nodeSkills(input),
|
||||
},
|
||||
},
|
||||
consoleQuery: {
|
||||
workflowDraft: {
|
||||
nodeSkills: {
|
||||
queryKey: (input: unknown) => mocks.nodeSkillsQueryKey(input),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => mocks.store,
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useNodeSkills', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
data: {
|
||||
type: 'llm',
|
||||
prompt_template: [{ text: 'first prompt', skill: true }],
|
||||
},
|
||||
},
|
||||
]
|
||||
mocks.store.getState.mockImplementation(() => ({
|
||||
getNodes: () => mocks.nodes,
|
||||
}))
|
||||
mocks.nodeSkills.mockResolvedValue({
|
||||
tool_dependencies: [],
|
||||
})
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
it('should avoid refetching when the request key stays the same', async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ promptTemplateKey }) => useNodeSkills({
|
||||
nodeId: 'node-1',
|
||||
promptTemplateKey,
|
||||
}),
|
||||
{
|
||||
initialProps: { promptTemplateKey: 'prompt-key-1' },
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.nodeSkills).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
mocks.nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
data: {
|
||||
type: 'llm',
|
||||
prompt_template: [{ text: 'updated prompt', skill: true }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
rerender({ promptTemplateKey: 'prompt-key-1' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.nodeSkills).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should refetch with the latest node data when the request key changes', async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ promptTemplateKey }) => useNodeSkills({
|
||||
nodeId: 'node-1',
|
||||
promptTemplateKey,
|
||||
}),
|
||||
{
|
||||
initialProps: { promptTemplateKey: 'prompt-key-1' },
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.nodeSkills).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
mocks.nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
data: {
|
||||
type: 'llm',
|
||||
prompt_template: [{ text: 'updated prompt', skill: true }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
rerender({ promptTemplateKey: 'prompt-key-2' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.nodeSkills).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
expect(mocks.nodeSkills).toHaveBeenLastCalledWith({
|
||||
params: { appId: 'app-1' },
|
||||
body: {
|
||||
type: 'llm',
|
||||
prompt_template: [{ text: 'updated prompt', skill: true }],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ToolSetting } from '../types'
|
||||
import type { ToolDependency } from '../use-node-skills'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
@@ -19,7 +20,10 @@ type Props = {
|
||||
onChange: (enabled: boolean) => void
|
||||
nodeId: string
|
||||
toolSettings?: ToolSetting[]
|
||||
promptTemplateKey: string
|
||||
toolDependencies: ToolDependency[]
|
||||
isNodeSkillsLoading: boolean
|
||||
isNodeSkillsQueryEnabled: boolean
|
||||
hasNodeSkillsData: boolean
|
||||
}
|
||||
|
||||
const ComputerUseConfig: FC<Props> = ({
|
||||
@@ -30,7 +34,10 @@ const ComputerUseConfig: FC<Props> = ({
|
||||
onChange,
|
||||
nodeId,
|
||||
toolSettings,
|
||||
promptTemplateKey,
|
||||
toolDependencies,
|
||||
isNodeSkillsLoading,
|
||||
isNodeSkillsQueryEnabled,
|
||||
hasNodeSkillsData,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isDisabled = readonly || isDisabledByStructuredOutput
|
||||
@@ -89,7 +96,10 @@ const ComputerUseConfig: FC<Props> = ({
|
||||
isComputerUseEnabled={enabled}
|
||||
nodeId={nodeId}
|
||||
toolSettings={toolSettings}
|
||||
promptTemplateKey={promptTemplateKey}
|
||||
toolDependencies={toolDependencies}
|
||||
isNodeSkillsLoading={isNodeSkillsLoading}
|
||||
isNodeSkillsQueryEnabled={isNodeSkillsQueryEnabled}
|
||||
hasNodeSkillsData={hasNodeSkillsData}
|
||||
/>
|
||||
</div>
|
||||
</FieldCollapse>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { useNodeCurdKit } from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { useNodeSkills } from '@/app/components/workflow/nodes/llm/use-node-skills'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools'
|
||||
@@ -26,7 +25,10 @@ type ReferenceToolConfigProps = {
|
||||
isComputerUseEnabled: boolean
|
||||
nodeId: string
|
||||
toolSettings?: ToolSetting[]
|
||||
promptTemplateKey: string
|
||||
toolDependencies: ToolDependency[]
|
||||
isNodeSkillsLoading: boolean
|
||||
isNodeSkillsQueryEnabled: boolean
|
||||
hasNodeSkillsData: boolean
|
||||
}
|
||||
|
||||
type ToolProviderGroup = {
|
||||
@@ -40,7 +42,10 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
|
||||
isComputerUseEnabled,
|
||||
nodeId,
|
||||
toolSettings,
|
||||
promptTemplateKey,
|
||||
toolDependencies,
|
||||
isNodeSkillsLoading,
|
||||
isNodeSkillsQueryEnabled,
|
||||
hasNodeSkillsData,
|
||||
}) => {
|
||||
const isReferenceToolsDisabled = readonly || !isComputerUseEnabled || isDisabledByStructuredOutput
|
||||
const { i18n, t } = useTranslation()
|
||||
@@ -52,11 +57,6 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const locale = useMemo(() => getLanguage(i18n.language as Locale), [i18n.language])
|
||||
|
||||
const { toolDependencies, isLoading, isQueryEnabled, hasData } = useNodeSkills({
|
||||
nodeId,
|
||||
promptTemplateKey,
|
||||
})
|
||||
|
||||
const providers = useMemo<ToolProviderGroup[]>(() => {
|
||||
const map = new Map<string, ToolDependency[]>()
|
||||
toolDependencies.forEach((tool) => {
|
||||
@@ -185,7 +185,7 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const isInitialLoading = isQueryEnabled && isLoading && !hasData
|
||||
const isInitialLoading = isNodeSkillsQueryEnabled && isNodeSkillsLoading && !hasNodeSkillsData
|
||||
const showNoData = !isInitialLoading && providers.length === 0
|
||||
|
||||
const renderProviderIcon = useCallback((providerId: string) => {
|
||||
|
||||
@@ -2,9 +2,8 @@ import type { FC } from 'react'
|
||||
import type { LLMNodeType } from './types'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import { RiAlertFill, RiInformationLine, RiQuestionLine } from '@remixicon/react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AddButton2 from '@/app/components/base/button/add-button'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
@@ -36,6 +35,7 @@ import { useStructuredOutputMutualExclusion } from './use-structured-output-mutu
|
||||
import { getLLMModelIssue, LLMModelIssueCode } from './utils'
|
||||
|
||||
const i18nPrefix = 'nodes.llm'
|
||||
const SKILL_DEPENDENCY_DEBOUNCE_MS = 800
|
||||
|
||||
const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
id,
|
||||
@@ -89,14 +89,29 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
}
|
||||
}, [inputs.prompt_template])
|
||||
const [skillsRefreshKey, setSkillsRefreshKey] = React.useState(promptTemplateKey)
|
||||
const { run: scheduleSkillsRefresh } = useDebounceFn((nextKey: string) => {
|
||||
setSkillsRefreshKey(nextKey)
|
||||
}, { wait: 3000 })
|
||||
const handlePromptEditorBlur = useCallback(() => {
|
||||
scheduleSkillsRefresh(promptTemplateKey)
|
||||
}, [promptTemplateKey, scheduleSkillsRefresh])
|
||||
useEffect(() => {
|
||||
if (skillsRefreshKey === promptTemplateKey)
|
||||
return
|
||||
|
||||
const { toolDependencies } = useNodeSkills({
|
||||
const timerId = window.setTimeout(() => {
|
||||
setSkillsRefreshKey(promptTemplateKey)
|
||||
}, SKILL_DEPENDENCY_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
}, [promptTemplateKey, skillsRefreshKey])
|
||||
|
||||
const handlePromptEditorBlur = useCallback(() => {
|
||||
setSkillsRefreshKey(promptTemplateKey)
|
||||
}, [promptTemplateKey])
|
||||
|
||||
const {
|
||||
toolDependencies,
|
||||
isLoading: isNodeSkillsLoading,
|
||||
isQueryEnabled: isNodeSkillsQueryEnabled,
|
||||
hasData: hasNodeSkillsData,
|
||||
} = useNodeSkills({
|
||||
nodeId: id,
|
||||
promptTemplateKey: skillsRefreshKey,
|
||||
enabled: isSupportSandbox,
|
||||
@@ -303,7 +318,10 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
onChange={handleComputerUseChange}
|
||||
nodeId={id}
|
||||
toolSettings={inputs.tool_settings}
|
||||
promptTemplateKey={skillsRefreshKey}
|
||||
toolDependencies={toolDependencies}
|
||||
isNodeSkillsLoading={isNodeSkillsLoading}
|
||||
isNodeSkillsQueryEnabled={isNodeSkillsQueryEnabled}
|
||||
hasNodeSkillsData={hasNodeSkillsData}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
|
||||
@@ -21,7 +21,6 @@ type UseNodeSkillsParams = {
|
||||
export function useNodeSkills({ nodeId, promptTemplateKey, enabled = true }: UseNodeSkillsParams) {
|
||||
const appId = useAppStore(s => s.appDetail?.id)
|
||||
const store = useStoreApi()
|
||||
const nodeData = useReactFlowStore(s => s.getNodes().find(n => n.id === nodeId)?.data)
|
||||
const isQueryEnabled = enabled && !!appId && !!nodeId
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
@@ -34,10 +33,9 @@ export function useNodeSkills({ nodeId, promptTemplateKey, enabled = true }: Use
|
||||
}),
|
||||
nodeId,
|
||||
promptTemplateKey,
|
||||
nodeData,
|
||||
store,
|
||||
]
|
||||
}, [appId, nodeId, promptTemplateKey, nodeData, store])
|
||||
}, [appId, nodeId, promptTemplateKey, store])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useSkillBatchUpload } from '../use-skill-batch-upload'
|
||||
|
||||
type MockWorkflowState = {
|
||||
setUploadStatus: ReturnType<typeof vi.fn>
|
||||
setUploadProgress: ReturnType<typeof vi.fn>
|
||||
openTab: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mutateAsync: vi.fn(),
|
||||
emitTreeUpdate: vi.fn(),
|
||||
workflowState: {
|
||||
setUploadStatus: vi.fn(),
|
||||
setUploadProgress: vi.fn(),
|
||||
openTab: vi.fn(),
|
||||
} as MockWorkflowState,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useBatchUpload: () => ({
|
||||
mutateAsync: mocks.mutateAsync,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => mocks.workflowState,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useSkillBatchUpload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.mutateAsync.mockResolvedValue([])
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Upload State', () => {
|
||||
it('should mark upload as in progress with the provided total', () => {
|
||||
const { result } = renderHook(() => useSkillBatchUpload())
|
||||
|
||||
act(() => {
|
||||
result.current.startUpload(3)
|
||||
})
|
||||
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('uploading')
|
||||
expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 3, failed: 0 })
|
||||
})
|
||||
|
||||
it('should update upload progress through the shared helper', () => {
|
||||
const { result } = renderHook(() => useSkillBatchUpload())
|
||||
|
||||
act(() => {
|
||||
result.current.setUploadProgress(2, 5)
|
||||
})
|
||||
|
||||
expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 2, total: 5, failed: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tree Upload', () => {
|
||||
it('should upload the tree and broadcast success when the batch upload succeeds', async () => {
|
||||
mocks.mutateAsync.mockResolvedValueOnce([
|
||||
{ id: 'folder-id', name: 'alpha', node_type: 'folder', size: 0, children: [] },
|
||||
])
|
||||
const { result } = renderHook(() => useSkillBatchUpload())
|
||||
const files = new Map([['alpha/SKILL.md', new File(['content'], 'SKILL.md')]])
|
||||
const tree = [{ name: 'alpha', node_type: 'folder' as const, children: [] }]
|
||||
|
||||
let uploadedNodes: unknown
|
||||
await act(async () => {
|
||||
uploadedNodes = await result.current.uploadTree({ tree, files })
|
||||
})
|
||||
|
||||
expect(mocks.mutateAsync).toHaveBeenCalledWith({
|
||||
appId: 'app-1',
|
||||
tree,
|
||||
files,
|
||||
parentId: null,
|
||||
onProgress: expect.any(Function),
|
||||
})
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('success')
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(uploadedNodes).toEqual([
|
||||
{ id: 'folder-id', name: 'alpha', node_type: 'folder', size: 0, children: [] },
|
||||
])
|
||||
})
|
||||
|
||||
it('should skip the upload mutation when app id is missing', async () => {
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
const { result } = renderHook(() => useSkillBatchUpload())
|
||||
const files = new Map([['alpha/SKILL.md', new File(['content'], 'SKILL.md')]])
|
||||
|
||||
let uploadedNodes: unknown
|
||||
await act(async () => {
|
||||
uploadedNodes = await result.current.uploadTree({
|
||||
tree: [{ name: 'alpha', node_type: 'folder', children: [] }],
|
||||
files,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mocks.mutateAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.workflowState.setUploadStatus).not.toHaveBeenCalledWith('success')
|
||||
expect(uploadedNodes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skill Document Opening', () => {
|
||||
it('should open the first nested SKILL.md file when present', () => {
|
||||
const { result } = renderHook(() => useSkillBatchUpload())
|
||||
|
||||
let openedId: string | null = null
|
||||
act(() => {
|
||||
openedId = result.current.openCreatedSkillDocument([
|
||||
{
|
||||
id: 'folder-id',
|
||||
name: 'alpha',
|
||||
node_type: 'folder',
|
||||
size: 0,
|
||||
children: [
|
||||
{
|
||||
id: 'skill-md-id',
|
||||
name: 'SKILL.md',
|
||||
node_type: 'file',
|
||||
size: 12,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
expect(mocks.workflowState.openTab).toHaveBeenCalledWith('skill-md-id', { pinned: true })
|
||||
expect(openedId).toBe('skill-md-id')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,32 +1,16 @@
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import CreateBlankSkillModal from './create-blank-skill-modal'
|
||||
|
||||
type MockWorkflowState = {
|
||||
setUploadStatus: ReturnType<typeof vi.fn>
|
||||
setUploadProgress: ReturnType<typeof vi.fn>
|
||||
openTab: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mutateAsync: vi.fn(),
|
||||
emitTreeUpdate: vi.fn(),
|
||||
appId: 'app-1',
|
||||
startUpload: vi.fn(),
|
||||
failUpload: vi.fn(),
|
||||
uploadTree: vi.fn(),
|
||||
openCreatedSkillDocument: vi.fn(),
|
||||
prepareSkillUploadFile: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
existingNames: new Set<string>(),
|
||||
workflowState: {
|
||||
setUploadStatus: vi.fn(),
|
||||
setUploadProgress: vi.fn(),
|
||||
openTab: vi.fn(),
|
||||
} as MockWorkflowState,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useBatchUpload: () => ({
|
||||
mutateAsync: mocks.mutateAsync,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
@@ -35,17 +19,17 @@ vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/skill-upload-utils', () => ({
|
||||
prepareSkillUploadFile: (...args: unknown[]) => mocks.prepareSkillUploadFile(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => mocks.workflowState,
|
||||
vi.mock('./use-skill-batch-upload', () => ({
|
||||
useSkillBatchUpload: () => ({
|
||||
appId: mocks.appId,
|
||||
startUpload: mocks.startUpload,
|
||||
failUpload: mocks.failUpload,
|
||||
uploadTree: mocks.uploadTree,
|
||||
openCreatedSkillDocument: mocks.openCreatedSkillDocument,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -60,10 +44,9 @@ describe('CreateBlankSkillModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.existingNames = new Set()
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
mocks.appId = 'app-1'
|
||||
mocks.prepareSkillUploadFile.mockImplementation(async (file: File) => file)
|
||||
mocks.uploadTree.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -102,27 +85,22 @@ describe('CreateBlankSkillModal', () => {
|
||||
describe('Create Flow', () => {
|
||||
it('should upload skill template and notify success when creation succeeds', async () => {
|
||||
const onClose = vi.fn()
|
||||
mocks.mutateAsync.mockImplementationOnce(async ({ onProgress }: { onProgress?: (uploaded: number, total: number) => void }) => {
|
||||
onProgress?.(1, 1)
|
||||
return [{
|
||||
children: [{ id: 'skill-md-id' }],
|
||||
}]
|
||||
})
|
||||
mocks.uploadTree.mockResolvedValueOnce([
|
||||
{ id: 'skill-folder-id', name: 'new-skill', node_type: 'folder', size: 0, children: [] },
|
||||
])
|
||||
render(<CreateBlankSkillModal isOpen onClose={onClose} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.uploadTree).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(2, 'success')
|
||||
expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 1, failed: 0 })
|
||||
expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 1, failed: 0 })
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.workflowState.openTab).toHaveBeenCalledWith('skill-md-id', { pinned: true })
|
||||
expect(mocks.startUpload).toHaveBeenCalledWith(1)
|
||||
expect(mocks.openCreatedSkillDocument).toHaveBeenCalledWith([
|
||||
{ id: 'skill-folder-id', name: 'new-skill', node_type: 'folder', size: 0, children: [] },
|
||||
])
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('workflow.skill.startTab.createSuccess:{"name":"new-skill"}')
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
@@ -131,14 +109,14 @@ describe('CreateBlankSkillModal', () => {
|
||||
|
||||
it('should set partial error and show error toast when upload fails', async () => {
|
||||
const onClose = vi.fn()
|
||||
mocks.mutateAsync.mockRejectedValueOnce(new Error('upload failed'))
|
||||
mocks.uploadTree.mockRejectedValueOnce(new Error('upload failed'))
|
||||
render(<CreateBlankSkillModal isOpen onClose={onClose} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
|
||||
expect(mocks.failUpload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skill.startTab.createError')
|
||||
@@ -148,17 +126,17 @@ describe('CreateBlankSkillModal', () => {
|
||||
})
|
||||
|
||||
it('should not start upload when app id is missing', () => {
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
mocks.appId = ''
|
||||
render(<CreateBlankSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i }))
|
||||
|
||||
expect(mocks.mutateAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.uploadTree).not.toHaveBeenCalled()
|
||||
expect(mocks.startUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should trigger create flow when Enter key is pressed and form is valid', async () => {
|
||||
mocks.mutateAsync.mockResolvedValueOnce([])
|
||||
render(<CreateBlankSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
@@ -166,7 +144,7 @@ describe('CreateBlankSkillModal', () => {
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.uploadTree).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,16 +3,13 @@
|
||||
import type { BatchUploadNodeInput } from '@/types/app-asset'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useBatchUpload } from '@/service/use-app-asset'
|
||||
import { useExistingSkillNames } from '../hooks/file-tree/data/use-skill-asset-tree'
|
||||
import { useSkillTreeUpdateEmitter } from '../hooks/file-tree/data/use-skill-tree-collaboration'
|
||||
import { prepareSkillUploadFile } from '../utils/skill-upload-utils'
|
||||
import { useSkillBatchUpload } from './use-skill-batch-upload'
|
||||
|
||||
const SKILL_MD_TEMPLATE = (name: string) => `---
|
||||
name: ${name}
|
||||
@@ -32,17 +29,13 @@ const CreateBlankSkillModal = ({ isOpen, onClose }: CreateBlankSkillModalProps)
|
||||
const [skillName, setSkillName] = useState('')
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
const storeApi = useWorkflowStore()
|
||||
|
||||
const batchUpload = useBatchUpload()
|
||||
const batchUploadRef = useRef(batchUpload)
|
||||
batchUploadRef.current = batchUpload
|
||||
|
||||
const emitTreeUpdate = useSkillTreeUpdateEmitter()
|
||||
const emitTreeUpdateRef = useRef(emitTreeUpdate)
|
||||
emitTreeUpdateRef.current = emitTreeUpdate
|
||||
const {
|
||||
appId,
|
||||
startUpload,
|
||||
failUpload,
|
||||
uploadTree,
|
||||
openCreatedSkillDocument,
|
||||
} = useSkillBatchUpload()
|
||||
|
||||
const { data: existingNames } = useExistingSkillNames()
|
||||
|
||||
@@ -69,8 +62,7 @@ const CreateBlankSkillModal = ({ isOpen, onClose }: CreateBlankSkillModalProps)
|
||||
return
|
||||
|
||||
setIsCreating(true)
|
||||
storeApi.getState().setUploadStatus('uploading')
|
||||
storeApi.getState().setUploadProgress({ uploaded: 0, total: 1, failed: 0 })
|
||||
startUpload(1)
|
||||
|
||||
try {
|
||||
const content = SKILL_MD_TEMPLATE(trimmedName)
|
||||
@@ -86,35 +78,21 @@ const CreateBlankSkillModal = ({ isOpen, onClose }: CreateBlankSkillModalProps)
|
||||
const files = new Map<string, File>()
|
||||
files.set(`${trimmedName}/SKILL.md`, preparedFile)
|
||||
|
||||
const createdNodes = await batchUploadRef.current.mutateAsync({
|
||||
appId,
|
||||
tree,
|
||||
files,
|
||||
parentId: null,
|
||||
onProgress: (uploaded, total) => {
|
||||
storeApi.getState().setUploadProgress({ uploaded, total, failed: 0 })
|
||||
},
|
||||
})
|
||||
|
||||
storeApi.getState().setUploadStatus('success')
|
||||
emitTreeUpdateRef.current()
|
||||
|
||||
const skillMdId = createdNodes?.[0]?.children?.[0]?.id
|
||||
if (skillMdId)
|
||||
storeApi.getState().openTab(skillMdId, { pinned: true })
|
||||
const createdNodes = await uploadTree({ tree, files })
|
||||
openCreatedSkillDocument(createdNodes)
|
||||
|
||||
toast.success(t('skill.startTab.createSuccess', { ns: 'workflow', name: trimmedName }))
|
||||
onClose()
|
||||
}
|
||||
catch {
|
||||
storeApi.getState().setUploadStatus('partial_error')
|
||||
failUpload()
|
||||
toast.error(t('skill.startTab.createError', { ns: 'workflow' }))
|
||||
}
|
||||
finally {
|
||||
setIsCreating(false)
|
||||
setSkillName('')
|
||||
}
|
||||
}, [canCreate, appId, trimmedName, storeApi, onClose, t])
|
||||
}, [appId, canCreate, failUpload, onClose, openCreatedSkillDocument, startUpload, t, trimmedName, uploadTree])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ZipValidationError } from '../utils/zip-extract'
|
||||
import ImportSkillModal from './import-skill-modal'
|
||||
|
||||
type MockWorkflowState = {
|
||||
setUploadStatus: ReturnType<typeof vi.fn>
|
||||
setUploadProgress: ReturnType<typeof vi.fn>
|
||||
openTab: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
appId: 'app-1',
|
||||
extractAndValidateZip: vi.fn(),
|
||||
buildUploadDataFromZip: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
emitTreeUpdate: vi.fn(),
|
||||
startUpload: vi.fn(),
|
||||
setUploadProgress: vi.fn(),
|
||||
failUpload: vi.fn(),
|
||||
uploadTree: vi.fn(),
|
||||
openCreatedSkillDocument: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
existingNames: new Set<string>(),
|
||||
workflowState: {
|
||||
setUploadStatus: vi.fn(),
|
||||
setUploadProgress: vi.fn(),
|
||||
openTab: vi.fn(),
|
||||
} as MockWorkflowState,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/zip-extract', () => {
|
||||
@@ -46,25 +37,20 @@ vi.mock('../utils/zip-to-upload-tree', () => ({
|
||||
buildUploadDataFromZip: (...args: unknown[]) => mocks.buildUploadDataFromZip(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useBatchUpload: () => ({
|
||||
mutateAsync: mocks.mutateAsync,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
useExistingSkillNames: () => ({
|
||||
data: mocks.existingNames,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => mocks.workflowState,
|
||||
vi.mock('./use-skill-batch-upload', () => ({
|
||||
useSkillBatchUpload: () => ({
|
||||
appId: mocks.appId,
|
||||
startUpload: mocks.startUpload,
|
||||
setUploadProgress: mocks.setUploadProgress,
|
||||
failUpload: mocks.failUpload,
|
||||
uploadTree: mocks.uploadTree,
|
||||
openCreatedSkillDocument: mocks.openCreatedSkillDocument,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -98,9 +84,8 @@ describe('ImportSkillModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.existingNames = new Set()
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
mocks.appId = 'app-1'
|
||||
mocks.uploadTree.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -188,28 +173,23 @@ describe('ImportSkillModal', () => {
|
||||
tree: [{ name: 'new-skill', node_type: 'folder', children: [] }],
|
||||
files: new Map([['new-skill/SKILL.md', new File(['content'], 'SKILL.md')]]),
|
||||
})
|
||||
mocks.mutateAsync.mockImplementationOnce(async ({ onProgress }: { onProgress?: (uploaded: number, total: number) => void }) => {
|
||||
onProgress?.(1, 1)
|
||||
return [{
|
||||
children: [{ id: 'skill-md-id', name: 'SKILL.md' }],
|
||||
}]
|
||||
})
|
||||
mocks.uploadTree.mockResolvedValueOnce([
|
||||
{ id: 'skill-folder-id', name: 'new-skill', node_type: 'folder', size: 0, children: [] },
|
||||
])
|
||||
|
||||
render(<ImportSkillModal isOpen onClose={onClose} />)
|
||||
selectFile(createZipFile('new-skill.zip', 2048))
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.uploadTree).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(2, 'success')
|
||||
expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 0, failed: 0 })
|
||||
expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 1, failed: 0 })
|
||||
expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 1, failed: 0 })
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.workflowState.openTab).toHaveBeenCalledWith('skill-md-id', { pinned: true })
|
||||
expect(mocks.startUpload).toHaveBeenCalledWith(0)
|
||||
expect(mocks.setUploadProgress).toHaveBeenCalledWith(0, 1)
|
||||
expect(mocks.openCreatedSkillDocument).toHaveBeenCalledWith([
|
||||
{ id: 'skill-folder-id', name: 'new-skill', node_type: 'folder', size: 0, children: [] },
|
||||
])
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('workflow.skill.startTab.importModal.importSuccess:{"name":"new-skill"}')
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
@@ -227,17 +207,17 @@ describe('ImportSkillModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
|
||||
expect(mocks.failUpload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skill.startTab.importModal.nameDuplicate')
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||
expect(mocks.buildUploadDataFromZip).not.toHaveBeenCalled()
|
||||
expect(mocks.mutateAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.uploadTree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not start import when app id is missing', () => {
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
mocks.appId = ''
|
||||
render(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
selectFile(createZipFile('new-skill.zip', 2048))
|
||||
@@ -245,8 +225,8 @@ describe('ImportSkillModal', () => {
|
||||
|
||||
expect(mocks.extractAndValidateZip).not.toHaveBeenCalled()
|
||||
expect(mocks.buildUploadDataFromZip).not.toHaveBeenCalled()
|
||||
expect(mocks.mutateAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.workflowState.setUploadStatus).not.toHaveBeenCalled()
|
||||
expect(mocks.uploadTree).not.toHaveBeenCalled()
|
||||
expect(mocks.startUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should map zip validation error code to localized error message', async () => {
|
||||
@@ -257,7 +237,7 @@ describe('ImportSkillModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
|
||||
expect(mocks.failUpload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skill.startTab.importModal.errorEmptyZip')
|
||||
@@ -274,7 +254,7 @@ describe('ImportSkillModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
|
||||
expect(mocks.failUpload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('custom zip error')
|
||||
@@ -289,7 +269,7 @@ describe('ImportSkillModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
|
||||
expect(mocks.failUpload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skill.startTab.importModal.errorInvalidZip')
|
||||
|
||||
@@ -3,16 +3,13 @@
|
||||
import type { ChangeEvent, DragEvent } from 'react'
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useBatchUpload } from '@/service/use-app-asset'
|
||||
import { useExistingSkillNames } from '../hooks/file-tree/data/use-skill-asset-tree'
|
||||
import { useSkillTreeUpdateEmitter } from '../hooks/file-tree/data/use-skill-tree-collaboration'
|
||||
import { extractAndValidateZip, ZipValidationError } from '../utils/zip-extract'
|
||||
import { buildUploadDataFromZip } from '../utils/zip-to-upload-tree'
|
||||
import { useSkillBatchUpload } from './use-skill-batch-upload'
|
||||
|
||||
const NS = 'workflow'
|
||||
const PREFIX = 'skill.startTab.importModal'
|
||||
@@ -46,17 +43,14 @@ const ImportSkillModal = ({ isOpen, onClose }: ImportSkillModalProps) => {
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
const storeApi = useWorkflowStore()
|
||||
|
||||
const batchUpload = useBatchUpload()
|
||||
const batchUploadRef = useRef(batchUpload)
|
||||
batchUploadRef.current = batchUpload
|
||||
|
||||
const emitTreeUpdate = useSkillTreeUpdateEmitter()
|
||||
const emitTreeUpdateRef = useRef(emitTreeUpdate)
|
||||
emitTreeUpdateRef.current = emitTreeUpdate
|
||||
const {
|
||||
appId,
|
||||
startUpload,
|
||||
setUploadProgress,
|
||||
failUpload,
|
||||
uploadTree,
|
||||
openCreatedSkillDocument,
|
||||
} = useSkillBatchUpload()
|
||||
|
||||
const { data: existingNames } = useExistingSkillNames()
|
||||
|
||||
@@ -107,8 +101,7 @@ const ImportSkillModal = ({ isOpen, onClose }: ImportSkillModalProps) => {
|
||||
return
|
||||
|
||||
setIsImporting(true)
|
||||
storeApi.getState().setUploadStatus('uploading')
|
||||
storeApi.getState().setUploadProgress({ uploaded: 0, total: 0, failed: 0 })
|
||||
startUpload(0)
|
||||
|
||||
try {
|
||||
const zipData = await selectedFile.arrayBuffer()
|
||||
@@ -116,38 +109,21 @@ const ImportSkillModal = ({ isOpen, onClose }: ImportSkillModalProps) => {
|
||||
|
||||
if (existingNames?.has(extracted.rootFolderName)) {
|
||||
toast.error(t(`${PREFIX}.nameDuplicate`, { ns: NS }))
|
||||
failUpload()
|
||||
setIsImporting(false)
|
||||
storeApi.getState().setUploadStatus('partial_error')
|
||||
return
|
||||
}
|
||||
|
||||
const { tree, files } = await buildUploadDataFromZip(extracted)
|
||||
|
||||
storeApi.getState().setUploadProgress({ uploaded: 0, total: files.size, failed: 0 })
|
||||
|
||||
const createdNodes = await batchUploadRef.current.mutateAsync({
|
||||
appId,
|
||||
tree,
|
||||
files,
|
||||
parentId: null,
|
||||
onProgress: (uploaded, total) => {
|
||||
storeApi.getState().setUploadProgress({ uploaded, total, failed: 0 })
|
||||
},
|
||||
})
|
||||
|
||||
storeApi.getState().setUploadStatus('success')
|
||||
emitTreeUpdateRef.current()
|
||||
|
||||
const skillFolder = createdNodes?.[0]
|
||||
const skillMd = skillFolder?.children?.find(c => c.name === 'SKILL.md')
|
||||
if (skillMd?.id)
|
||||
storeApi.getState().openTab(skillMd.id, { pinned: true })
|
||||
setUploadProgress(0, files.size)
|
||||
const createdNodes = await uploadTree({ tree, files })
|
||||
openCreatedSkillDocument(createdNodes)
|
||||
|
||||
toast.success(t(`${PREFIX}.importSuccess`, { ns: NS, name: extracted.rootFolderName }))
|
||||
onClose()
|
||||
}
|
||||
catch (error) {
|
||||
storeApi.getState().setUploadStatus('partial_error')
|
||||
failUpload()
|
||||
if (error instanceof ZipValidationError) {
|
||||
const i18nKey = ZIP_ERROR_I18N_KEYS[error.code as keyof typeof ZIP_ERROR_I18N_KEYS]
|
||||
toast.error(i18nKey ? t(i18nKey, { ns: NS }) : error.message)
|
||||
@@ -160,7 +136,7 @@ const ImportSkillModal = ({ isOpen, onClose }: ImportSkillModalProps) => {
|
||||
setIsImporting(false)
|
||||
setSelectedFile(null)
|
||||
}
|
||||
}, [selectedFile, appId, storeApi, existingNames, t, onClose])
|
||||
}, [selectedFile, appId, startUpload, existingNames, setUploadProgress, uploadTree, openCreatedSkillDocument, t, onClose, failUpload])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import SkillTemplatesSection from './skill-templates-section'
|
||||
|
||||
type MockWorkflowState = {
|
||||
setUploadStatus: ReturnType<typeof vi.fn>
|
||||
setUploadProgress: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type TemplateEntry = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -17,15 +10,13 @@ type TemplateEntry = {
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
appId: 'app-1',
|
||||
templates: [] as TemplateEntry[],
|
||||
buildUploadDataFromTemplate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
emitTreeUpdate: vi.fn(),
|
||||
startUpload: vi.fn(),
|
||||
failUpload: vi.fn(),
|
||||
uploadTree: vi.fn(),
|
||||
existingNames: new Set<string>(),
|
||||
workflowState: {
|
||||
setUploadStatus: vi.fn(),
|
||||
setUploadProgress: vi.fn(),
|
||||
} as MockWorkflowState,
|
||||
}))
|
||||
|
||||
vi.mock('./templates/registry', () => ({
|
||||
@@ -36,25 +27,18 @@ vi.mock('./templates/template-to-upload', () => ({
|
||||
buildUploadDataFromTemplate: (...args: unknown[]) => mocks.buildUploadDataFromTemplate(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useBatchUpload: () => ({
|
||||
mutateAsync: mocks.mutateAsync,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
useExistingSkillNames: () => ({
|
||||
data: mocks.existingNames,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => mocks.workflowState,
|
||||
vi.mock('./use-skill-batch-upload', () => ({
|
||||
useSkillBatchUpload: () => ({
|
||||
appId: mocks.appId,
|
||||
startUpload: mocks.startUpload,
|
||||
failUpload: mocks.failUpload,
|
||||
uploadTree: mocks.uploadTree,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -87,10 +71,8 @@ describe('SkillTemplatesSection', () => {
|
||||
tree: [{ name: 'alpha', node_type: 'folder', children: [] }],
|
||||
files: new Map([['alpha/SKILL.md', new File(['content'], 'SKILL.md')]]),
|
||||
})
|
||||
mocks.mutateAsync.mockResolvedValue([])
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
mocks.uploadTree.mockResolvedValue([])
|
||||
mocks.appId = 'app-1'
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -125,47 +107,43 @@ describe('SkillTemplatesSection', () => {
|
||||
})
|
||||
|
||||
describe('Use Template Flow', () => {
|
||||
it('should upload template and update workflow status when use action succeeds', async () => {
|
||||
mocks.mutateAsync.mockImplementationOnce(async ({ onProgress }: { onProgress?: (uploaded: number, total: number) => void }) => {
|
||||
onProgress?.(1, 1)
|
||||
return []
|
||||
})
|
||||
it('should upload template when use action succeeds', async () => {
|
||||
render(<SkillTemplatesSection />)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.uploadTree).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(mocks.buildUploadDataFromTemplate).toHaveBeenCalledWith('alpha', expect.any(Array))
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(2, 'success')
|
||||
expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 1, failed: 0 })
|
||||
expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 1, failed: 0 })
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.startUpload).toHaveBeenCalledWith(1)
|
||||
const uploadArg = mocks.uploadTree.mock.calls[0][0]
|
||||
expect(uploadArg.tree).toEqual([{ name: 'alpha', node_type: 'folder', children: [] }])
|
||||
expect(uploadArg.files).toBeInstanceOf(Map)
|
||||
expect(uploadArg.files.get('alpha/SKILL.md')).toBeInstanceOf(File)
|
||||
})
|
||||
|
||||
it('should set partial error when upload fails', async () => {
|
||||
mocks.mutateAsync.mockRejectedValueOnce(new Error('upload failed'))
|
||||
mocks.uploadTree.mockRejectedValueOnce(new Error('upload failed'))
|
||||
render(<SkillTemplatesSection />)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
|
||||
expect(mocks.failUpload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not start upload when app id is missing', () => {
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
mocks.appId = ''
|
||||
render(<SkillTemplatesSection />)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })[0])
|
||||
|
||||
expect(mocks.templates[0].loadContent).not.toHaveBeenCalled()
|
||||
expect(mocks.mutateAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.workflowState.setUploadStatus).not.toHaveBeenCalled()
|
||||
expect(mocks.uploadTree).not.toHaveBeenCalled()
|
||||
expect(mocks.startUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,72 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import type { SkillTemplateSummary } from './templates/types'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { SearchMenu } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useBatchUpload } from '@/service/use-app-asset'
|
||||
import { useExistingSkillNames } from '../hooks/file-tree/data/use-skill-asset-tree'
|
||||
import { useSkillTreeUpdateEmitter } from '../hooks/file-tree/data/use-skill-tree-collaboration'
|
||||
import SectionHeader from './section-header'
|
||||
import TemplateCard from './template-card'
|
||||
import TemplateSearch from './template-search'
|
||||
import { SKILL_TEMPLATES } from './templates/registry'
|
||||
import { buildUploadDataFromTemplate } from './templates/template-to-upload'
|
||||
import { useSkillBatchUpload } from './use-skill-batch-upload'
|
||||
|
||||
const SkillTemplatesSection = () => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null)
|
||||
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
const storeApi = useWorkflowStore()
|
||||
const batchUpload = useBatchUpload()
|
||||
const batchUploadRef = useRef(batchUpload)
|
||||
batchUploadRef.current = batchUpload
|
||||
const emitTreeUpdate = useSkillTreeUpdateEmitter()
|
||||
const emitTreeUpdateRef = useRef(emitTreeUpdate)
|
||||
emitTreeUpdateRef.current = emitTreeUpdate
|
||||
const { appId, startUpload, failUpload, uploadTree } = useSkillBatchUpload()
|
||||
|
||||
const { data: existingNames } = useExistingSkillNames()
|
||||
const existingNamesRef = useRef(existingNames)
|
||||
existingNamesRef.current = existingNames
|
||||
|
||||
const handleUse = useCallback(async (summary: SkillTemplateSummary) => {
|
||||
const entry = SKILL_TEMPLATES.find(e => e.id === summary.id)
|
||||
if (!entry || !appId || existingNamesRef.current?.has(summary.id))
|
||||
if (!entry || !appId || existingNames?.has(summary.id))
|
||||
return
|
||||
|
||||
setLoadingId(summary.id)
|
||||
storeApi.getState().setUploadStatus('uploading')
|
||||
storeApi.getState().setUploadProgress({ uploaded: 0, total: 1, failed: 0 })
|
||||
startUpload(1)
|
||||
|
||||
try {
|
||||
const children = await entry.loadContent()
|
||||
const uploadData = await buildUploadDataFromTemplate(summary.id, children)
|
||||
|
||||
await batchUploadRef.current.mutateAsync({
|
||||
appId,
|
||||
tree: uploadData.tree,
|
||||
files: uploadData.files,
|
||||
parentId: null,
|
||||
onProgress: (uploaded, total) => {
|
||||
storeApi.getState().setUploadProgress({ uploaded, total, failed: 0 })
|
||||
},
|
||||
})
|
||||
|
||||
storeApi.getState().setUploadStatus('success')
|
||||
emitTreeUpdateRef.current()
|
||||
await uploadTree(uploadData)
|
||||
}
|
||||
catch {
|
||||
storeApi.getState().setUploadStatus('partial_error')
|
||||
failUpload()
|
||||
}
|
||||
finally {
|
||||
setLoadingId(null)
|
||||
}
|
||||
}, [appId, storeApi])
|
||||
}, [appId, existingNames, failUpload, startUpload, uploadTree])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { BatchUploadNodeInput, BatchUploadNodeOutput } from '@/types/app-asset'
|
||||
import { useCallback } from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useBatchUpload } from '@/service/use-app-asset'
|
||||
import { useSkillTreeUpdateEmitter } from '../hooks/file-tree/data/use-skill-tree-collaboration'
|
||||
|
||||
type UploadTreeParams = {
|
||||
tree: BatchUploadNodeInput[]
|
||||
files: Map<string, File>
|
||||
}
|
||||
|
||||
const createProgress = (uploaded: number, total: number) => ({
|
||||
uploaded,
|
||||
total,
|
||||
failed: 0,
|
||||
})
|
||||
|
||||
function findSkillDocumentNodeId(nodes: BatchUploadNodeOutput[]): string | null {
|
||||
const queue = [...nodes]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!
|
||||
if (node.name === 'SKILL.md')
|
||||
return node.id
|
||||
|
||||
if (node.children.length > 0)
|
||||
queue.push(...node.children)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const useSkillBatchUpload = () => {
|
||||
const appId = useAppStore(s => s.appDetail?.id || '')
|
||||
const storeApi = useWorkflowStore()
|
||||
const { mutateAsync } = useBatchUpload()
|
||||
const emitTreeUpdate = useSkillTreeUpdateEmitter()
|
||||
|
||||
const startUpload = useCallback((total: number) => {
|
||||
const normalizedTotal = Math.max(total, 0)
|
||||
const state = storeApi.getState()
|
||||
state.setUploadStatus('uploading')
|
||||
state.setUploadProgress(createProgress(0, normalizedTotal))
|
||||
}, [storeApi])
|
||||
|
||||
const setUploadProgress = useCallback((uploaded: number, total: number) => {
|
||||
storeApi.getState().setUploadProgress(createProgress(uploaded, total))
|
||||
}, [storeApi])
|
||||
|
||||
const failUpload = useCallback(() => {
|
||||
storeApi.getState().setUploadStatus('partial_error')
|
||||
}, [storeApi])
|
||||
|
||||
const uploadTree = useCallback(async ({
|
||||
tree,
|
||||
files,
|
||||
}: UploadTreeParams): Promise<BatchUploadNodeOutput[]> => {
|
||||
if (!appId)
|
||||
return []
|
||||
|
||||
const createdNodes = await mutateAsync({
|
||||
appId,
|
||||
tree,
|
||||
files,
|
||||
parentId: null,
|
||||
onProgress: setUploadProgress,
|
||||
})
|
||||
|
||||
storeApi.getState().setUploadStatus('success')
|
||||
emitTreeUpdate()
|
||||
return createdNodes
|
||||
}, [appId, emitTreeUpdate, mutateAsync, setUploadProgress, storeApi])
|
||||
|
||||
const openCreatedSkillDocument = useCallback((nodes: BatchUploadNodeOutput[]): string | null => {
|
||||
const skillDocumentId = findSkillDocumentNodeId(nodes)
|
||||
if (skillDocumentId)
|
||||
storeApi.getState().openTab(skillDocumentId, { pinned: true })
|
||||
return skillDocumentId
|
||||
}, [storeApi])
|
||||
|
||||
return {
|
||||
appId,
|
||||
startUpload,
|
||||
setUploadProgress,
|
||||
failUpload,
|
||||
uploadTree,
|
||||
openCreatedSkillDocument,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user