mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
## Summary - Show a canvas-based loading spinner on image upload nodes (LoadImage) during file upload via drag-drop, paste, or file picker - Display the uploading file's name immediately in the filename dropdown instead of showing the previous file's name - Show the uploading audio file's name immediately in the audio widget during upload ## Changes - **`useNodeImageUpload.ts`**: Add `isUploading` flag and `onUploadStart` callback to the upload lifecycle; clear `node.imgs` during upload to prevent stale previews - **`useImagePreviewWidget.ts`**: Add `renderUploadSpinner` that draws an animated arc spinner on the canvas when `node.isUploading` is true; guard against empty `imgs` array - **`useImageUploadWidget.ts`**: Set `fileComboWidget.value` to the new filename on upload start; clear `node.imgs` on combo widget change - **`uploadAudio.ts`**: Set `audioWidget.value` to the new filename on upload start - **`litegraph-augmentation.d.ts`**: Add `isUploading` property to `LGraphNode` https://github.com/user-attachments/assets/818ce529-cb83-428a-8c98-dd900a128343 ## Test plan - [x] Upload an image via file picker on LoadImage node — spinner shows during upload, filename updates immediately - [x] Drag-and-drop an image onto LoadImage node — same behavior - [x] Paste an image onto LoadImage node — same behavior - [x] Change the dropdown selection on LoadImage — old preview clears, new image loads - [x] Upload an audio file — filename updates immediately in the widget 🤖 Generated with [Claude Code](https://claude.com/claude-code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9189-feat-show-loading-spinner-and-uploading-filename-during-image-upload-3126d73d365081e4af27cd7252f34298) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
176 lines
4.7 KiB
TypeScript
176 lines
4.7 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
|
|
const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
|
|
mockFetchApi: vi.fn(),
|
|
mockAddAlert: vi.fn(),
|
|
mockUpdateInputs: vi.fn()
|
|
}))
|
|
|
|
let capturedDragOnDrop: (files: File[]) => Promise<string[]>
|
|
|
|
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
|
|
useNodeDragAndDrop: (
|
|
_node: LGraphNode,
|
|
opts: { onDrop: typeof capturedDragOnDrop }
|
|
) => {
|
|
capturedDragOnDrop = opts.onDrop
|
|
}
|
|
}))
|
|
|
|
vi.mock('@/composables/node/useNodeFileInput', () => ({
|
|
useNodeFileInput: () => ({ openFileSelection: vi.fn() })
|
|
}))
|
|
|
|
vi.mock('@/composables/node/useNodePaste', () => ({
|
|
useNodePaste: vi.fn()
|
|
}))
|
|
|
|
vi.mock('@/i18n', () => ({
|
|
t: (key: string) => key
|
|
}))
|
|
|
|
vi.mock('@/platform/updates/common/toastStore', () => ({
|
|
useToastStore: () => ({ addAlert: mockAddAlert })
|
|
}))
|
|
|
|
vi.mock('@/scripts/api', () => ({
|
|
api: { fetchApi: mockFetchApi }
|
|
}))
|
|
|
|
vi.mock('@/stores/assetsStore', () => ({
|
|
useAssetsStore: () => ({ updateInputs: mockUpdateInputs })
|
|
}))
|
|
|
|
function createMockNode(): LGraphNode {
|
|
return {
|
|
isUploading: false,
|
|
imgs: [new Image()],
|
|
graph: { setDirtyCanvas: vi.fn() },
|
|
size: [300, 400]
|
|
} as unknown as LGraphNode
|
|
}
|
|
|
|
function createFile(name = 'test.png'): File {
|
|
return new File(['data'], name, { type: 'image/png' })
|
|
}
|
|
|
|
function successResponse(name: string, subfolder?: string) {
|
|
return {
|
|
status: 200,
|
|
json: () => Promise.resolve({ name, subfolder })
|
|
}
|
|
}
|
|
|
|
function failResponse(status = 500) {
|
|
return {
|
|
status,
|
|
statusText: 'Server Error'
|
|
}
|
|
}
|
|
|
|
describe('useNodeImageUpload', () => {
|
|
let node: LGraphNode
|
|
let onUploadComplete: (paths: string[]) => void
|
|
let onUploadStart: (files: File[]) => void
|
|
let onUploadError: () => void
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
node = createMockNode()
|
|
onUploadComplete = vi.fn()
|
|
onUploadStart = vi.fn()
|
|
onUploadError = vi.fn()
|
|
|
|
const { useNodeImageUpload } = await import('./useNodeImageUpload')
|
|
useNodeImageUpload(node, {
|
|
onUploadComplete,
|
|
onUploadStart,
|
|
onUploadError,
|
|
folder: 'input'
|
|
})
|
|
})
|
|
|
|
it('sets isUploading true during upload and false after', async () => {
|
|
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
|
|
|
const promise = capturedDragOnDrop([createFile()])
|
|
expect(node.isUploading).toBe(true)
|
|
|
|
await promise
|
|
expect(node.isUploading).toBe(false)
|
|
})
|
|
|
|
it('clears node.imgs on upload start', async () => {
|
|
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
|
|
|
const promise = capturedDragOnDrop([createFile()])
|
|
expect(node.imgs).toBeUndefined()
|
|
|
|
await promise
|
|
})
|
|
|
|
it('calls onUploadStart with files', async () => {
|
|
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
|
const files = [createFile()]
|
|
|
|
await capturedDragOnDrop(files)
|
|
expect(onUploadStart).toHaveBeenCalledWith(files)
|
|
})
|
|
|
|
it('calls onUploadComplete with valid paths on success', async () => {
|
|
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
|
|
|
await capturedDragOnDrop([createFile()])
|
|
expect(onUploadComplete).toHaveBeenCalledWith(['test.png'])
|
|
})
|
|
|
|
it('includes subfolder in returned path', async () => {
|
|
mockFetchApi.mockResolvedValueOnce(successResponse('test.png', 'pasted'))
|
|
|
|
await capturedDragOnDrop([createFile()])
|
|
expect(onUploadComplete).toHaveBeenCalledWith(['pasted/test.png'])
|
|
})
|
|
|
|
it('calls onUploadError when all uploads fail', async () => {
|
|
mockFetchApi.mockResolvedValueOnce(failResponse())
|
|
|
|
await capturedDragOnDrop([createFile()])
|
|
expect(onUploadError).toHaveBeenCalled()
|
|
expect(onUploadComplete).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('resets isUploading even when upload fails', async () => {
|
|
mockFetchApi.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
await capturedDragOnDrop([createFile()])
|
|
expect(node.isUploading).toBe(false)
|
|
})
|
|
|
|
it('rejects concurrent uploads with a toast', async () => {
|
|
mockFetchApi.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) =>
|
|
setTimeout(() => resolve(successResponse('a.png')), 50)
|
|
)
|
|
)
|
|
|
|
const first = capturedDragOnDrop([createFile('a.png')])
|
|
const second = await capturedDragOnDrop([createFile('b.png')])
|
|
|
|
expect(second).toEqual([])
|
|
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadAlreadyInProgress')
|
|
|
|
await first
|
|
})
|
|
|
|
it('calls setDirtyCanvas on start and finish', async () => {
|
|
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
|
|
|
await capturedDragOnDrop([createFile()])
|
|
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|