fix: prevent duplicate skill file creation submits

This commit is contained in:
yyh
2026-03-24 19:37:29 +08:00
parent 27f32ce383
commit 29469a8600
4 changed files with 187 additions and 45 deletions

View File

@ -10,28 +10,30 @@ import { START_TAB_ID } from '../../../constants'
import { useInlineCreateNode } from './use-inline-create-node'
const {
mockUploadMutateAsync,
mockCreateFolderMutateAsync,
mockRenameMutateAsync,
mockUploadMutate,
mockCreateFolderMutate,
mockRenameMutate,
mockEmitTreeUpdate,
mockToastNotify,
mockToastSuccess,
mockToastError,
} = vi.hoisted(() => ({
mockUploadMutateAsync: vi.fn(),
mockCreateFolderMutateAsync: vi.fn(),
mockRenameMutateAsync: vi.fn(),
mockUploadMutate: vi.fn(),
mockCreateFolderMutate: vi.fn(),
mockRenameMutate: vi.fn(),
mockEmitTreeUpdate: vi.fn(),
mockToastNotify: vi.fn(),
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/service/use-app-asset', () => ({
useUploadFileWithPresignedUrl: () => ({
mutateAsync: mockUploadMutateAsync,
mutate: mockUploadMutate,
}),
useCreateAppAssetFolder: () => ({
mutateAsync: mockCreateFolderMutateAsync,
mutate: mockCreateFolderMutate,
}),
useRenameAppAssetNode: () => ({
mutateAsync: mockRenameMutateAsync,
mutate: mockRenameMutate,
}),
}))
@ -39,17 +41,18 @@ vi.mock('../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mockEmitTreeUpdate,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mockToastNotify,
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}))
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
return ({ children }: { children: ReactNode }) => (
<WorkflowContext.Provider value={store}>
<WorkflowContext value={store}>
{children}
</WorkflowContext.Provider>
</WorkflowContext>
)
}
@ -64,9 +67,11 @@ describe('useInlineCreateNode', () => {
it('should open created text file tab with editor auto focus intent', async () => {
const store = createWorkflowStore({})
const treeRef = { current: null } as React.RefObject<TreeApi<TreeNodeData> | null>
mockUploadMutateAsync.mockResolvedValue({
id: 'file-1',
extension: 'md',
mockUploadMutate.mockImplementation((_, options) => {
options?.onSuccess?.({
id: 'file-1',
extension: 'md',
})
})
store.getState().startCreateNode('file', null)
@ -84,19 +89,22 @@ describe('useInlineCreateNode', () => {
})
})
expect(mockUploadMutateAsync).toHaveBeenCalledTimes(1)
expect(mockUploadMutate).toHaveBeenCalledTimes(1)
expect(store.getState().activeTabId).toBe('file-1')
expect(store.getState().editorAutoFocusFileId).toBe('file-1')
expect(store.getState().openTabIds).toEqual(['file-1'])
expect(store.getState().pendingCreateNode).toBeNull()
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.skillSidebar.menu.fileCreated')
})
it('should not open tab for non-text-like created files', async () => {
const store = createWorkflowStore({})
const treeRef = { current: null } as React.RefObject<TreeApi<TreeNodeData> | null>
mockUploadMutateAsync.mockResolvedValue({
id: 'file-2',
extension: 'png',
mockUploadMutate.mockImplementation((_, options) => {
options?.onSuccess?.({
id: 'file-2',
extension: 'png',
})
})
store.getState().startCreateNode('file', null)
@ -114,10 +122,42 @@ describe('useInlineCreateNode', () => {
})
})
expect(mockUploadMutateAsync).toHaveBeenCalledTimes(1)
expect(mockUploadMutate).toHaveBeenCalledTimes(1)
expect(store.getState().activeTabId).toBe(START_TAB_ID)
expect(store.getState().editorAutoFocusFileId).toBeNull()
expect(store.getState().openTabIds).toEqual([])
expect(store.getState().pendingCreateNode).toBeNull()
})
it('should wait for rename mutation callbacks before resolving existing node rename', async () => {
const store = createWorkflowStore({})
const treeRef = { current: null } as React.RefObject<TreeApi<TreeNodeData> | null>
let onSuccess: (() => void) | undefined
mockRenameMutate.mockImplementation((_, options) => {
onSuccess = () => options?.onSuccess?.({})
})
const { result } = renderHook(() => useInlineCreateNode({
treeRef,
treeChildren: [],
}), { wrapper: createWrapper(store) })
let resolved = false
const renamePromise = act(async () => {
await result.current.handleRename({
id: 'file-1',
name: 'renamed.ts',
})
resolved = true
})
expect(resolved).toBe(false)
onSuccess?.()
await renamePromise
expect(mockRenameMutate).toHaveBeenCalledTimes(1)
expect(mockEmitTreeUpdate).toHaveBeenCalled()
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.skillSidebar.menu.renamed')
})
})

View File

@ -26,6 +26,15 @@ type RenamePayload = {
name: string
}
type MutationWithCallbacks<TData, TVariables> = {
mutate: (variables: TVariables, options?: {
onSuccess?: (data: TData) => void
onError?: () => void
}) => void
}
type MutationResult<TData> = { ok: true, data: TData } | { ok: false }
export function useInlineCreateNode({
treeRef,
treeChildren,
@ -56,6 +65,18 @@ export function useInlineCreateNode({
return insertDraftTreeNode(treeChildren, pendingCreateNode.parentId, draftNode)
}, [pendingCreateNode, treeChildren])
const runMutation = useCallback(<TData, TVariables>(
mutation: MutationWithCallbacks<TData, TVariables>,
variables: TVariables,
) => {
return new Promise<MutationResult<TData>>((resolve) => {
mutation.mutate(variables, {
onSuccess: data => resolve({ ok: true, data }),
onError: () => resolve({ ok: false }),
})
})
}, [])
const handleRename = useCallback(async ({ id, name }: RenamePayload) => {
if (pendingCreateId && id === pendingCreateId) {
const trimmedName = name.trim()
@ -66,50 +87,58 @@ export function useInlineCreateNode({
try {
if (pendingCreateType === 'folder') {
await createFolder.mutateAsync({
const createFolderResult = await runMutation(createFolder, {
appId,
payload: {
name: trimmedName,
parent_id: pendingCreateParentId,
},
})
if (!createFolderResult.ok) {
toast.error(t('skillSidebar.menu.createError'))
return
}
emitTreeUpdate()
toast.success(t('skillSidebar.menu.folderCreated'))
}
else {
const emptyBlob = new Blob([''], { type: 'text/plain' })
const file = new File([emptyBlob], trimmedName)
const createdFile = await uploadFile.mutateAsync({
const createFileResult = await runMutation(uploadFile, {
appId,
file,
parentId: pendingCreateParentId,
})
if (!createFileResult.ok) {
toast.error(t('skillSidebar.menu.createError'))
return
}
emitTreeUpdate()
const extension = getFileExtension(trimmedName, createdFile.extension)
const extension = getFileExtension(trimmedName, createFileResult.data.extension)
if (isTextLikeFile(extension))
storeApi.getState().openTab(createdFile.id, { pinned: true, autoFocusEditor: true })
storeApi.getState().openTab(createFileResult.data.id, { pinned: true, autoFocusEditor: true })
toast.success(t('skillSidebar.menu.fileCreated'))
}
}
catch {
toast.error(t('skillSidebar.menu.createError'))
}
finally {
storeApi.getState().clearCreateNode()
}
return
}
renameNode.mutateAsync({
const renameResult = await runMutation(renameNode, {
appId,
nodeId: id,
payload: { name },
}).then(() => {
})
if (renameResult.ok) {
emitTreeUpdate()
toast.success(t('skillSidebar.menu.renamed'))
}).catch(() => {
}
else {
toast.error(t('skillSidebar.menu.renameError'))
})
}
}, [
appId,
uploadFile,
@ -118,6 +147,7 @@ export function useInlineCreateNode({
pendingCreateParentId,
pendingCreateType,
renameNode,
runMutation,
storeApi,
t,
emitTreeUpdate,