mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 16:26:25 +08:00
chore: upload file and auto insert
This commit is contained in:
@@ -9,6 +9,7 @@ import type {
|
||||
VariableBlockType,
|
||||
WorkflowVariableBlockType,
|
||||
} from '../../../types'
|
||||
import type { TreeNodeData } from '@/app/components/workflow/skill/type'
|
||||
import type { NodeOutPutVar, Var } from '@/app/components/workflow/types'
|
||||
import type { EventEmitterValue } from '@/context/event-emitter'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
|
||||
import { FileReferenceNode } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
@@ -64,6 +66,73 @@ beforeAll(() => {
|
||||
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
|
||||
})
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
uploadedResourceIds: ['11111111-1111-1111-1111-111111111111'],
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-panel', () => ({
|
||||
FilePickerPanel: ({
|
||||
onSelectNode,
|
||||
showAddFiles,
|
||||
onAddFiles,
|
||||
}: {
|
||||
onSelectNode: (node: TreeNodeData) => void
|
||||
showAddFiles?: boolean
|
||||
onAddFiles?: () => void
|
||||
}) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectNode({
|
||||
id: '33333333-3333-3333-3333-333333333333',
|
||||
node_type: 'file',
|
||||
name: 'existing.md',
|
||||
path: '/existing.md',
|
||||
extension: 'md',
|
||||
size: 1,
|
||||
children: [],
|
||||
})}
|
||||
>
|
||||
mock-existing-file
|
||||
</button>
|
||||
{showAddFiles && (
|
||||
<button type="button" onClick={onAddFiles}>
|
||||
mock-add-files
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-upload-modal', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
onUploadedFiles,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onUploadedFiles?: (resourceIds: string[]) => void
|
||||
}) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUploadedFiles?.(mocks.uploadedResourceIds)}
|
||||
>
|
||||
mock-upload-success
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/component', () => ({
|
||||
default: ({ resourceId }: { resourceId: string }) => (
|
||||
<span>{`mock-file-reference:${resourceId}`}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
// ─── Typed factories (no `any` / `never`) ────────────────────────────────────
|
||||
|
||||
function makeContextBlock(overrides: Partial<ContextBlockType> = {}): ContextBlockType {
|
||||
@@ -150,6 +219,7 @@ const MinimalEditor: React.FC<{
|
||||
currentBlock?: CurrentBlockType
|
||||
errorMessageBlock?: ErrorMessageBlockType
|
||||
lastRunBlock?: LastRunBlockType
|
||||
isSupportSandbox?: boolean
|
||||
captures: Captures
|
||||
}> = ({
|
||||
triggerString,
|
||||
@@ -160,10 +230,12 @@ const MinimalEditor: React.FC<{
|
||||
currentBlock,
|
||||
errorMessageBlock,
|
||||
lastRunBlock,
|
||||
isSupportSandbox,
|
||||
captures,
|
||||
}) => {
|
||||
const initialConfig = React.useMemo(() => ({
|
||||
namespace: `component-picker-test-${Math.random().toString(16).slice(2)}`,
|
||||
nodes: [FileReferenceNode],
|
||||
onError: (e: Error) => {
|
||||
throw e
|
||||
},
|
||||
@@ -189,6 +261,7 @@ const MinimalEditor: React.FC<{
|
||||
currentBlock={currentBlock}
|
||||
errorMessageBlock={errorMessageBlock}
|
||||
lastRunBlock={lastRunBlock}
|
||||
isSupportSandbox={isSupportSandbox}
|
||||
/>
|
||||
</LexicalComposer>
|
||||
</EventEmitterContextProvider>
|
||||
@@ -707,179 +780,32 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('blur/focus menu visibility', () => {
|
||||
it('hides the menu after a 200ms delay when blur command is dispatched', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
it('inserts uploaded files into the editor after add files succeeds from the sandbox slash menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="{"
|
||||
contextBlock={makeContextBlock()}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="/"
|
||||
workflowVariableBlock={makeWorkflowVariableBlock({}, [
|
||||
makeWorkflowVarNode('node-1', 'Node 1', [makeWorkflowNodeVar('output', VarType.string)]),
|
||||
])}
|
||||
isSupportSandbox
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '{', true)
|
||||
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '/', true)
|
||||
await flushNextTick()
|
||||
|
||||
vi.useFakeTimers()
|
||||
await user.click(await screen.findByText('workflow.nodes.llm.files'))
|
||||
await user.click(await screen.findByRole('button', { name: 'mock-add-files' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'mock-upload-success' }))
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('restores menu visibility when focus command is dispatched after blur hides it', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="{"
|
||||
contextBlock={makeContextBlock()}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '{', true)
|
||||
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus'))
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
|
||||
await setEditorText(editor, '{', true)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels the blur timer when focus arrives before the 200ms timeout', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="{"
|
||||
contextBlock={makeContextBlock()}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '{', true)
|
||||
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus'))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cancels a pending blur timer when a subsequent blur targets var-search-input', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="{"
|
||||
contextBlock={makeContextBlock()}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '{', true)
|
||||
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
|
||||
})
|
||||
|
||||
const varInput = document.createElement('input')
|
||||
varInput.classList.add('var-search-input')
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: varInput }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not hide the menu when blur target is var-search-input', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="{"
|
||||
contextBlock={makeContextBlock()}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '{', true)
|
||||
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
const target = document.createElement('input')
|
||||
target.classList.add('var-search-input')
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: target }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
await waitFor(() => {
|
||||
expect(readEditorText(editor)).toContain('§[file].[app].[11111111-1111-1111-1111-111111111111]§')
|
||||
expect(readEditorText(editor)).not.toContain('/')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
||||
import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$insertNodes,
|
||||
@@ -228,16 +229,41 @@ const ComponentPicker = ({
|
||||
[editor],
|
||||
)
|
||||
|
||||
const handleSelectFileReference = useCallback((resourceId: string) => {
|
||||
editor.update(() => {
|
||||
const match = checkForTriggerMatch(triggerString, editor)
|
||||
const nodeToRemove = match ? $splitNodeContainingQuery(match) : null
|
||||
if (nodeToRemove)
|
||||
nodeToRemove.remove()
|
||||
const removeTriggerText = useCallback(() => {
|
||||
const match = getMatchFromSelection()
|
||||
if (!match)
|
||||
return
|
||||
|
||||
$insertNodes([$createFileReferenceNode({ resourceId })])
|
||||
const nodeToRemove = $splitNodeContainingQuery(match)
|
||||
if (nodeToRemove)
|
||||
nodeToRemove.remove()
|
||||
}, [getMatchFromSelection])
|
||||
|
||||
const insertFileReferences = useCallback((resourceIds: string[]) => {
|
||||
if (!resourceIds.length)
|
||||
return
|
||||
|
||||
editor.focus(() => {
|
||||
editor.update(() => {
|
||||
removeTriggerText()
|
||||
if (!$isRangeSelection($getSelection()))
|
||||
$getRoot().selectEnd()
|
||||
|
||||
const fileNodes = resourceIds.flatMap((resourceId, index) => {
|
||||
const node = $createFileReferenceNode({ resourceId })
|
||||
if (index === resourceIds.length - 1)
|
||||
return [node]
|
||||
|
||||
return [node, $createTextNode(' ')]
|
||||
})
|
||||
$insertNodes(fileNodes)
|
||||
})
|
||||
})
|
||||
}, [checkForTriggerMatch, editor, triggerString])
|
||||
}, [editor, removeTriggerText])
|
||||
|
||||
const handleSelectFileReference = useCallback((resourceId: string) => {
|
||||
insertFileReferences([resourceId])
|
||||
}, [insertFileReferences])
|
||||
|
||||
const handleSelectWorkflowVariable = useCallback((variables: string[]) => {
|
||||
editor.update(() => {
|
||||
@@ -448,6 +474,9 @@ const ComponentPicker = ({
|
||||
showHeader={false}
|
||||
showAddFiles
|
||||
onAddFiles={() => {
|
||||
editor.update(() => {
|
||||
removeTriggerText()
|
||||
})
|
||||
setFileUploadModalKey(key => key + 1)
|
||||
setIsFileUploadModalOpen(true)
|
||||
handleClose()
|
||||
@@ -566,7 +595,7 @@ const ComponentPicker = ({
|
||||
}
|
||||
</>
|
||||
)
|
||||
}, [blurHidden, isAgentTrigger, isSupportSandbox, triggerString, allFlattenOptions.length, workflowVariableBlock?.show, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, workflowVariableBlock?.agentNodes, floatingStyles, isPositioned, refs, agentNodes, handleSelectAgent, handleClose, useExternalSearch, queryString, workflowVariableOptions, isSupportFileVar, showAssembleVariables, handleSelectAssembleVariables, currentBlock?.generatorType, t, activeTab, handleSelectWorkflowVariable, handleSelectFileReference, contextBlock?.show, contextBlock?.selectable, handleSelectContext, agentBlock?.show])
|
||||
}, [isAgentTrigger, blurHidden, isSupportSandbox, triggerString, allFlattenOptions.length, workflowVariableBlock?.show, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, workflowVariableBlock?.agentNodes, floatingStyles, isPositioned, refs, agentNodes, handleSelectAgent, handleClose, useExternalSearch, queryString, workflowVariableOptions, isSupportFileVar, showAssembleVariables, handleSelectAssembleVariables, currentBlock?.generatorType, t, activeTab, handleSelectWorkflowVariable, handleSelectFileReference, contextBlock?.show, contextBlock?.selectable, handleSelectContext, agentBlock?.show, editor, removeTriggerText])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -589,6 +618,7 @@ const ComponentPicker = ({
|
||||
key={fileUploadModalKey}
|
||||
isOpen={isFileUploadModalOpen}
|
||||
onClose={() => setIsFileUploadModalOpen(false)}
|
||||
onUploadedFiles={insertFileReferences}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -30,6 +30,7 @@ type FilePickerUploadModalProps = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
defaultFolderId?: string
|
||||
onUploadedFiles?: (resourceIds: string[]) => void
|
||||
}
|
||||
|
||||
type AddFileMode = 'create' | 'upload'
|
||||
@@ -133,6 +134,7 @@ const FilePickerUploadModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
defaultFolderId,
|
||||
onUploadedFiles,
|
||||
}: FilePickerUploadModalProps) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
@@ -191,6 +193,7 @@ const FilePickerUploadModal = ({
|
||||
appId,
|
||||
storeApi,
|
||||
onClose: noop,
|
||||
onFilesUploaded: uploadedNodes => onUploadedFiles?.(uploadedNodes.map(node => node.id)),
|
||||
})
|
||||
const isCreatingFile = uploadFile.isPending
|
||||
const isBusy = isCreating || isCreatingFile
|
||||
@@ -252,18 +255,19 @@ const FilePickerUploadModal = ({
|
||||
try {
|
||||
const emptyBlob = new Blob([''], { type: 'text/plain' })
|
||||
const file = new File([emptyBlob], trimmedFileName)
|
||||
await uploadFile.mutateAsync({
|
||||
const createdFile = await uploadFile.mutateAsync({
|
||||
appId,
|
||||
file,
|
||||
parentId: toApiParentId(effectiveUploadFolderId),
|
||||
})
|
||||
emitTreeUpdate()
|
||||
onUploadedFiles?.([createdFile.id])
|
||||
onClose()
|
||||
}
|
||||
catch {
|
||||
toast.error(t('skillSidebar.menu.createError'))
|
||||
}
|
||||
}, [appId, canCreate, effectiveUploadFolderId, emitTreeUpdate, onClose, t, trimmedFileName, uploadFile])
|
||||
}, [appId, canCreate, effectiveUploadFolderId, emitTreeUpdate, onClose, onUploadedFiles, t, trimmedFileName, uploadFile])
|
||||
const modeLabel = t('skillEditor.uploadIn')
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { SkillEditorSliceShape, UploadStatus } from '@/app/components/workflow/store/workflow/skill-editor/types'
|
||||
import type { BatchUploadNodeInput } from '@/types/app-asset'
|
||||
import type { AppAssetNode, BatchUploadNodeInput } from '@/types/app-asset'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useCreateOperations } from './use-create-operations'
|
||||
|
||||
@@ -28,7 +28,7 @@ const mocks = vi.hoisted(() => ({
|
||||
createFolderPending: false,
|
||||
uploadPending: false,
|
||||
batchPending: false,
|
||||
uploadMutateAsync: vi.fn<(payload: UploadMutationPayload) => Promise<void>>(),
|
||||
uploadMutateAsync: vi.fn<(payload: UploadMutationPayload) => Promise<AppAssetNode | undefined>>(),
|
||||
batchMutateAsync: vi.fn<(payload: BatchUploadMutationPayload) => Promise<unknown>>(),
|
||||
prepareSkillUploadFile: vi.fn<(file: File) => Promise<File>>(),
|
||||
emitTreeUpdate: vi.fn<() => void>(),
|
||||
@@ -88,6 +88,16 @@ const createInputChangeEvent = (files: File[] | null) => {
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
}
|
||||
|
||||
const createUploadedNode = (id: string, name: string): AppAssetNode => ({
|
||||
id,
|
||||
node_type: 'file',
|
||||
name,
|
||||
parent_id: null,
|
||||
order: 0,
|
||||
extension: name.split('.').pop() || '',
|
||||
size: 1,
|
||||
})
|
||||
|
||||
const withRelativePath = (file: File, relativePath: string): File => {
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: relativePath,
|
||||
@@ -245,6 +255,34 @@ describe('useCreateOperations', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should report successfully uploaded nodes to onFilesUploaded', async () => {
|
||||
const { storeApi } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const onFilesUploaded = vi.fn()
|
||||
const first = new File(['first'], 'first.md', { type: 'text/markdown' })
|
||||
const second = new File(['second'], 'second.txt', { type: 'text/plain' })
|
||||
const event = createInputChangeEvent([first, second])
|
||||
const uploadedFirst = createUploadedNode('11111111-1111-1111-1111-111111111111', 'first.md')
|
||||
const uploadedSecond = createUploadedNode('22222222-2222-2222-2222-222222222222', 'second.txt')
|
||||
mocks.uploadMutateAsync
|
||||
.mockResolvedValueOnce(uploadedFirst)
|
||||
.mockResolvedValueOnce(uploadedSecond)
|
||||
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: 'folder-success',
|
||||
appId: 'app-success',
|
||||
storeApi,
|
||||
onClose,
|
||||
onFilesUploaded,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileChange(event)
|
||||
})
|
||||
|
||||
expect(onFilesUploaded).toHaveBeenCalledWith([uploadedFirst, uploadedSecond])
|
||||
})
|
||||
|
||||
it('should set partial_error when some file uploads fail but still emit updates for uploaded files', async () => {
|
||||
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types'
|
||||
import type { BatchUploadNodeInput } from '@/types/app-asset'
|
||||
import type { AppAssetNode, BatchUploadNodeInput } from '@/types/app-asset'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import {
|
||||
useBatchUpload,
|
||||
@@ -17,6 +17,7 @@ type UseCreateOperationsOptions = {
|
||||
appId: string
|
||||
storeApi: StoreApi<SkillEditorSliceShape>
|
||||
onClose: () => void
|
||||
onFilesUploaded?: (nodes: AppAssetNode[]) => void
|
||||
}
|
||||
|
||||
const getRelativePath = (file: File) => {
|
||||
@@ -28,6 +29,7 @@ export function useCreateOperations({
|
||||
appId,
|
||||
storeApi,
|
||||
onClose,
|
||||
onFilesUploaded,
|
||||
}: UseCreateOperationsOptions) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -62,21 +64,27 @@ export function useCreateOperations({
|
||||
|
||||
try {
|
||||
const uploadFiles = await Promise.all(files.map(file => prepareSkillUploadFile(file)))
|
||||
await Promise.all(
|
||||
const uploadedNodes = (await Promise.all(
|
||||
uploadFiles.map(async (file) => {
|
||||
try {
|
||||
await uploadFileAsync({ appId, file, parentId })
|
||||
const node = await uploadFileAsync({ appId, file, parentId })
|
||||
progress.uploaded++
|
||||
return node
|
||||
}
|
||||
catch {
|
||||
progress.failed++
|
||||
return null
|
||||
}
|
||||
finally {
|
||||
storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
|
||||
}
|
||||
storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
|
||||
}),
|
||||
)
|
||||
)).filter((node): node is AppAssetNode => !!node)
|
||||
|
||||
storeApi.getState().setUploadStatus(progress.failed > 0 ? 'partial_error' : 'success')
|
||||
storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
|
||||
if (uploadedNodes.length > 0)
|
||||
onFilesUploaded?.(uploadedNodes)
|
||||
}
|
||||
catch {
|
||||
storeApi.getState().setUploadStatus('partial_error')
|
||||
@@ -87,7 +95,7 @@ export function useCreateOperations({
|
||||
e.target.value = ''
|
||||
onClose()
|
||||
}
|
||||
}, [appId, uploadFileAsync, onClose, parentId, storeApi, emitTreeUpdate])
|
||||
}, [appId, uploadFileAsync, onClose, onFilesUploaded, parentId, storeApi, emitTreeUpdate])
|
||||
|
||||
const handleFolderChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
|
||||
Reference in New Issue
Block a user