fix(skill): keep upload inputs mounted outside overlays

This commit is contained in:
yyh
2026-03-26 15:55:26 +08:00
parent 12ca422c8a
commit 5ce1dfa0bf
7 changed files with 95 additions and 52 deletions

View File

@ -26,8 +26,6 @@ type MockFileOperations = {
handleDownload: () => void
handleNewFile: () => void
handleNewFolder: () => void
handleFileChange: () => void
handleFolderChange: () => void
handleRename: () => void
handleDeleteClick: () => void
handleDeleteConfirm: () => void
@ -53,8 +51,6 @@ function createFileOperationsMock(): MockFileOperations {
handleDownload: vi.fn(),
handleNewFile: vi.fn(),
handleNewFolder: vi.fn(),
handleFileChange: vi.fn(),
handleFolderChange: vi.fn(),
handleRename: vi.fn(),
handleDeleteClick: vi.fn(),
handleDeleteConfirm: vi.fn(),
@ -106,8 +102,6 @@ const renderNodeMenu = ({
onDownload={mocks.fileOperations.handleDownload}
onNewFile={mocks.fileOperations.handleNewFile}
onNewFolder={mocks.fileOperations.handleNewFolder}
onFileChange={mocks.fileOperations.handleFileChange}
onFolderChange={mocks.fileOperations.handleFolderChange}
onRename={mocks.fileOperations.handleRename}
onDeleteClick={mocks.fileOperations.handleDeleteClick}
onImportSkills={onImportSkills}
@ -131,8 +125,6 @@ const renderNodeMenu = ({
onDownload={mocks.fileOperations.handleDownload}
onNewFile={mocks.fileOperations.handleNewFile}
onNewFolder={mocks.fileOperations.handleNewFolder}
onFileChange={mocks.fileOperations.handleFileChange}
onFolderChange={mocks.fileOperations.handleFolderChange}
onRename={mocks.fileOperations.handleRename}
onDeleteClick={mocks.fileOperations.handleDeleteClick}
onImportSkills={onImportSkills}
@ -205,13 +197,20 @@ describe('NodeMenu', () => {
})
it('should trigger hidden file and folder input clicks from upload actions', () => {
const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click')
const fileInput = document.createElement('input')
const folderInput = document.createElement('input')
const fileClickSpy = vi.spyOn(fileInput, 'click')
const folderClickSpy = vi.spyOn(folderInput, 'click')
mocks.fileOperations.fileInputRef.current = fileInput
mocks.fileOperations.folderInputRef.current = folderInput
renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER })
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.uploadFile/i }))
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.uploadFolder/i }))
expect(clickSpy).toHaveBeenCalledTimes(2)
expect(fileClickSpy).toHaveBeenCalledTimes(1)
expect(folderClickSpy).toHaveBeenCalledTimes(1)
})
it('should cut explicit action node ids and close menu when cut is clicked', () => {

View File

@ -32,8 +32,6 @@ type NodeMenuProps = {
onDownload: () => void
onNewFile: () => void
onNewFolder: () => void
onFileChange: React.ChangeEventHandler<HTMLInputElement>
onFolderChange: React.ChangeEventHandler<HTMLInputElement>
onRename: () => void
onDeleteClick: () => void
onImportSkills?: () => void
@ -51,8 +49,6 @@ const NodeMenu = ({
onDownload,
onNewFile,
onNewFolder,
onFileChange,
onFolderChange,
onRename,
onDeleteClick,
onImportSkills,
@ -97,24 +93,6 @@ const NodeMenu = ({
<>
{isFolder && (
<>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
aria-label={t('skillSidebar.menu.uploadFile')}
onChange={onFileChange}
/>
<input
ref={folderInputRef}
type="file"
// @ts-expect-error webkitdirectory is a non-standard attribute
webkitdirectory=""
className="hidden"
aria-label={t('skillSidebar.menu.uploadFolder')}
onChange={onFolderChange}
/>
<MenuItem
menuType={menuType}
icon={FileAdd}

View File

@ -15,6 +15,7 @@ import { NODE_MENU_TYPE, ROOT_ID } from '../../constants'
import { useFileOperations } from '../../hooks/file-tree/operations/use-file-operations'
import NodeDeleteConfirmDialog from './node-delete-confirm-dialog'
import NodeMenu from './node-menu'
import UploadInputs from './upload-inputs'
const ImportSkillModal = dynamic(() => import('../../start-tab/import-skill-modal'), {
ssr: false,
@ -106,6 +107,12 @@ const TreeContextMenu = ({
return (
<>
<UploadInputs
fileInputRef={fileOperations.fileInputRef}
folderInputRef={fileOperations.folderInputRef}
onFileChange={fileOperations.handleFileChange}
onFolderChange={fileOperations.handleFolderChange}
/>
<ContextMenu>
<ContextMenuTrigger
ref={triggerRef}
@ -127,8 +134,6 @@ const TreeContextMenu = ({
onDownload={fileOperations.handleDownload}
onNewFile={fileOperations.handleNewFile}
onNewFolder={fileOperations.handleNewFolder}
onFileChange={fileOperations.handleFileChange}
onFolderChange={fileOperations.handleFolderChange}
onRename={fileOperations.handleRename}
onDeleteClick={fileOperations.handleDeleteClick}
onImportSkills={isRootTarget ? handleOpenImportSkills : undefined}

View File

@ -21,6 +21,7 @@ import NodeMenu from './node-menu'
import TreeEditInput from './tree-edit-input'
import TreeGuideLines from './tree-guide-lines'
import { TreeNodeIcon } from './tree-node-icon'
import UploadInputs from './upload-inputs'
type TreeNodeProps = NodeRendererProps<TreeNodeData> & {
treeChildren: TreeNodeData[]
@ -101,6 +102,12 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
return (
<>
<UploadInputs
fileInputRef={fileOperations.fileInputRef}
folderInputRef={fileOperations.folderInputRef}
onFileChange={fileOperations.handleFileChange}
onFolderChange={fileOperations.handleFolderChange}
/>
<div
ref={dragHandle}
style={style}
@ -195,8 +202,6 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
onDownload={fileOperations.handleDownload}
onNewFile={fileOperations.handleNewFile}
onNewFolder={fileOperations.handleNewFolder}
onFileChange={fileOperations.handleFileChange}
onFolderChange={fileOperations.handleFolderChange}
onRename={fileOperations.handleRename}
onDeleteClick={fileOperations.handleDeleteClick}
/>

View File

@ -0,0 +1,44 @@
'use client'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type UploadInputsProps = {
fileInputRef: React.RefObject<HTMLInputElement | null>
folderInputRef: React.RefObject<HTMLInputElement | null>
onFileChange: React.ChangeEventHandler<HTMLInputElement>
onFolderChange: React.ChangeEventHandler<HTMLInputElement>
}
const UploadInputs = ({
fileInputRef,
folderInputRef,
onFileChange,
onFolderChange,
}: UploadInputsProps) => {
const { t } = useTranslation('workflow')
return (
<>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
aria-label={t('skillSidebar.menu.uploadFile')}
onChange={onFileChange}
/>
<input
ref={folderInputRef}
type="file"
// @ts-expect-error webkitdirectory is a non-standard attribute
webkitdirectory=""
className="hidden"
aria-label={t('skillSidebar.menu.uploadFolder')}
onChange={onFolderChange}
/>
</>
)
}
export default React.memo(UploadInputs)

View File

@ -162,6 +162,27 @@ describe('SidebarSearchAdd', () => {
expect(clickSpy).toHaveBeenCalledTimes(2)
})
it('should keep upload inputs mounted after menu closes so change handlers still run', () => {
// Arrange
render(<SidebarSearchAdd />)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.add/i }))
// Act
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.uploadFile/i }))
// Assert
const uploadFileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement | null
expect(uploadFileInput).not.toBeNull()
fireEvent.change(uploadFileInput!, {
target: {
files: [new File(['content'], 'readme.md', { type: 'text/markdown' })],
},
})
expect(mocks.fileOperations.handleFileChange).toHaveBeenCalledTimes(1)
})
it('should open and close import modal when import skills action is used', () => {
// Arrange
render(<SidebarSearchAdd />)

View File

@ -18,6 +18,7 @@ import dynamic from '@/next/dynamic'
import { cn } from '@/utils/classnames'
import { ROOT_ID } from '../constants'
import MenuItem from '../file-tree/tree/menu-item'
import UploadInputs from '../file-tree/tree/upload-inputs'
import { useSkillAssetTreeData } from '../hooks/file-tree/data/use-skill-asset-tree'
import { useFileOperations } from '../hooks/file-tree/operations/use-file-operations'
import { getTargetFolderIdFromSelection } from '../utils/tree-utils'
@ -69,6 +70,12 @@ const SidebarSearchAdd = () => {
return (
<div className="flex items-center gap-1 p-2">
<UploadInputs
fileInputRef={fileInputRef}
folderInputRef={folderInputRef}
onFileChange={handleFileChange}
onFolderChange={handleFolderChange}
/>
<SearchInput
value={searchValue}
onChange={v => storeApi.getState().setFileTreeSearchTerm(v)}
@ -91,22 +98,6 @@ const SidebarSearchAdd = () => {
sideOffset={4}
popupClassName="min-w-[180px]"
>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
<input
ref={folderInputRef}
type="file"
// @ts-expect-error webkitdirectory is a non-standard attribute
webkitdirectory=""
className="hidden"
onChange={handleFolderChange}
/>
<MenuItem
menuType="dropdown"
icon={FileAdd}