mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
11 Commits
austin/cre
...
glary/use-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1988cd49b | ||
|
|
0178003156 | ||
|
|
f9899fccaa | ||
|
|
027d21817d | ||
|
|
5e632ed274 | ||
|
|
258af63618 | ||
|
|
e57b6a52aa | ||
|
|
142c372823 | ||
|
|
e1dca0bcb2 | ||
|
|
82935499d8 | ||
|
|
9ef78eea0c |
@@ -658,8 +658,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
: '3d'
|
||||
|
||||
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
|
||||
sceneConfig.value.backgroundImage = uploadedPath
|
||||
await load3d?.setBackgroundImage(uploadedPath)
|
||||
if (uploadedPath) {
|
||||
sceneConfig.value.backgroundImage = uploadedPath
|
||||
await load3d?.setBackgroundImage(uploadedPath)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportModel = async (format: string) => {
|
||||
|
||||
193
src/composables/useUpload.test.ts
Normal file
193
src/composables/useUpload.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { UPLOAD_SKIPPED_ERROR, useUpload } from '@/composables/useUpload'
|
||||
import type { UploadResult } from '@/platform/assets/services/uploadService'
|
||||
|
||||
const { mockUploadMedia, mockUploadMediaBatch } = vi.hoisted(() => ({
|
||||
mockUploadMedia: vi.fn(),
|
||||
mockUploadMediaBatch: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/uploadService', () => ({
|
||||
uploadMedia: mockUploadMedia,
|
||||
uploadMediaBatch: mockUploadMediaBatch
|
||||
}))
|
||||
|
||||
const successResult = (path = 'input/pic.png'): UploadResult => ({
|
||||
success: true,
|
||||
path,
|
||||
name: path.split('/').pop() ?? path,
|
||||
subfolder: path.includes('/') ? path.split('/').slice(0, -1).join('/') : '',
|
||||
response: { name: path }
|
||||
})
|
||||
|
||||
const makeFile = (name = 'pic.png') =>
|
||||
new File([new Uint8Array([1, 2, 3])], name, { type: 'image/png' })
|
||||
|
||||
describe('useUpload', () => {
|
||||
beforeEach(() => {
|
||||
mockUploadMedia.mockReset()
|
||||
mockUploadMediaBatch.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sets loading true while upload is in progress', async () => {
|
||||
let resolveUpload!: (value: UploadResult) => void
|
||||
mockUploadMedia.mockReturnValue(
|
||||
new Promise<UploadResult>((resolve) => {
|
||||
resolveUpload = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { loading, upload } = useUpload()
|
||||
const promise = upload({ source: makeFile() })
|
||||
|
||||
await nextTick()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolveUpload(successResult())
|
||||
await promise
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('resets loading to false when upload fails unexpectedly', async () => {
|
||||
mockUploadMedia.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { loading, upload } = useUpload()
|
||||
|
||||
await expect(upload({ source: makeFile() })).rejects.toThrow(
|
||||
'Network error'
|
||||
)
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns the UploadResult from uploadMedia unchanged', async () => {
|
||||
const expected = successResult('sub/pic.png')
|
||||
mockUploadMedia.mockResolvedValue(expected)
|
||||
|
||||
const { upload } = useUpload()
|
||||
const result = await upload({ source: makeFile() })
|
||||
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it('passes input and config through to uploadMedia', async () => {
|
||||
mockUploadMedia.mockResolvedValue(successResult())
|
||||
|
||||
const { upload } = useUpload()
|
||||
const file = makeFile()
|
||||
await upload({ source: file }, { type: 'input', subfolder: 'sub' })
|
||||
|
||||
expect(mockUploadMedia).toHaveBeenCalledWith(
|
||||
{ source: file },
|
||||
{ type: 'input', subfolder: 'sub' }
|
||||
)
|
||||
})
|
||||
|
||||
it('skips concurrent calls while upload is in progress', async () => {
|
||||
let resolveFirst!: (value: UploadResult) => void
|
||||
mockUploadMedia.mockReturnValueOnce(
|
||||
new Promise<UploadResult>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { loading, upload } = useUpload()
|
||||
const first = upload({ source: makeFile('a.png') })
|
||||
await nextTick()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
const second = await upload({ source: makeFile('b.png') })
|
||||
expect(second.success).toBe(false)
|
||||
expect(second.error).toBe(UPLOAD_SKIPPED_ERROR)
|
||||
expect(mockUploadMedia).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveFirst(successResult())
|
||||
await first
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks loading independently per instance', async () => {
|
||||
let resolveFirst!: (value: UploadResult) => void
|
||||
mockUploadMedia
|
||||
.mockReturnValueOnce(
|
||||
new Promise<UploadResult>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(successResult('b.png'))
|
||||
|
||||
const first = useUpload()
|
||||
const second = useUpload()
|
||||
|
||||
const promise1 = first.upload({ source: makeFile('a.png') })
|
||||
await nextTick()
|
||||
|
||||
expect(first.loading.value).toBe(true)
|
||||
expect(second.loading.value).toBe(false)
|
||||
|
||||
await second.upload({ source: makeFile('b.png') })
|
||||
expect(second.loading.value).toBe(false)
|
||||
expect(first.loading.value).toBe(true)
|
||||
|
||||
resolveFirst(successResult('a.png'))
|
||||
await promise1
|
||||
expect(first.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uploadBatch sets loading during batch and passes inputs/config through', async () => {
|
||||
let resolveBatch!: (value: UploadResult[]) => void
|
||||
mockUploadMediaBatch.mockReturnValue(
|
||||
new Promise<UploadResult[]>((resolve) => {
|
||||
resolveBatch = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { loading, uploadBatch } = useUpload()
|
||||
const files = [makeFile('a.png'), makeFile('b.png')]
|
||||
const promise = uploadBatch(
|
||||
files.map((f) => ({ source: f })),
|
||||
{ type: 'input' }
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
expect(loading.value).toBe(true)
|
||||
expect(mockUploadMediaBatch).toHaveBeenCalledWith(
|
||||
[{ source: files[0] }, { source: files[1] }],
|
||||
{ type: 'input' }
|
||||
)
|
||||
|
||||
resolveBatch([successResult('a.png'), successResult('b.png')])
|
||||
await promise
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uploadBatch skips when already loading and returns a skipped result per input', async () => {
|
||||
let resolveFirst!: (value: UploadResult) => void
|
||||
mockUploadMedia.mockReturnValueOnce(
|
||||
new Promise<UploadResult>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { upload, uploadBatch } = useUpload()
|
||||
const first = upload({ source: makeFile('a.png') })
|
||||
await nextTick()
|
||||
|
||||
const batch = await uploadBatch([
|
||||
{ source: makeFile('b.png') },
|
||||
{ source: makeFile('c.png') }
|
||||
])
|
||||
expect(batch).toHaveLength(2)
|
||||
expect(batch.every((r) => !r.success)).toBe(true)
|
||||
expect(batch.every((r) => r.error === UPLOAD_SKIPPED_ERROR)).toBe(true)
|
||||
expect(mockUploadMediaBatch).not.toHaveBeenCalled()
|
||||
|
||||
resolveFirst(successResult('a.png'))
|
||||
await first
|
||||
})
|
||||
})
|
||||
64
src/composables/useUpload.ts
Normal file
64
src/composables/useUpload.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
uploadMedia,
|
||||
uploadMediaBatch
|
||||
} from '@/platform/assets/services/uploadService'
|
||||
import type {
|
||||
UploadConfig,
|
||||
UploadInput,
|
||||
UploadResult
|
||||
} from '@/platform/assets/services/uploadService'
|
||||
|
||||
export const UPLOAD_SKIPPED_ERROR = 'UPLOAD_SKIPPED_ALREADY_IN_PROGRESS'
|
||||
|
||||
function skippedResult(): UploadResult {
|
||||
return {
|
||||
success: false,
|
||||
path: '',
|
||||
name: '',
|
||||
subfolder: '',
|
||||
error: UPLOAD_SKIPPED_ERROR,
|
||||
response: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading-state wrapper around `uploadMedia` / `uploadMediaBatch`.
|
||||
* Concurrent calls while `loading` is true resolve to an unsuccessful
|
||||
* `UploadResult` with `error === UPLOAD_SKIPPED_ERROR` (matches
|
||||
* `uploadMedia`'s error-as-value pattern — callers check
|
||||
* `result.success`, not `try/catch`). Consumers surfacing errors to
|
||||
* the user should localize this sentinel at the UI boundary.
|
||||
*/
|
||||
export function useUpload() {
|
||||
const loading = ref(false)
|
||||
|
||||
async function upload(
|
||||
input: UploadInput,
|
||||
config?: UploadConfig
|
||||
): Promise<UploadResult> {
|
||||
if (loading.value) return skippedResult()
|
||||
loading.value = true
|
||||
try {
|
||||
return await uploadMedia(input, config)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBatch(
|
||||
inputs: UploadInput[],
|
||||
config?: UploadConfig
|
||||
): Promise<UploadResult[]> {
|
||||
if (loading.value) return inputs.map(() => skippedResult())
|
||||
loading.value = true
|
||||
try {
|
||||
return await uploadMediaBatch(inputs, config)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, upload, uploadBatch }
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { uploadMedia } from '@/platform/assets/services/uploadService'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
vi.mock('@/platform/assets/services/uploadService', () => ({
|
||||
uploadMedia: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
getRandParam: () => '?rand=123'
|
||||
}
|
||||
}))
|
||||
|
||||
describe('Load3dUtils.mapSceneLightIntensityToHdri', () => {
|
||||
it('maps scene slider low end to a small positive HDRI intensity', () => {
|
||||
@@ -23,3 +37,158 @@ describe('Load3dUtils.mapSceneLightIntensityToHdri', () => {
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(3, 5, 5)).toBe(0.25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3dUtils.uploadFile', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns the uploaded path on success', async () => {
|
||||
vi.mocked(uploadMedia).mockResolvedValue({
|
||||
success: true,
|
||||
path: 'subfolder/file.png',
|
||||
name: 'file.png',
|
||||
subfolder: 'subfolder',
|
||||
response: { name: 'file.png', subfolder: 'subfolder' }
|
||||
})
|
||||
|
||||
const file = new File(['x'], 'file.png')
|
||||
const result = await Load3dUtils.uploadFile(file, 'subfolder')
|
||||
|
||||
expect(result).toBe('subfolder/file.png')
|
||||
expect(uploadMedia).toHaveBeenCalledWith(
|
||||
{ source: file },
|
||||
{ subfolder: 'subfolder', maxSizeMB: Load3dUtils.MAX_UPLOAD_SIZE_MB }
|
||||
)
|
||||
})
|
||||
|
||||
it('shows file-too-large toast when size exceeds maximum', async () => {
|
||||
vi.mocked(uploadMedia).mockResolvedValue({
|
||||
success: false,
|
||||
path: '',
|
||||
name: '',
|
||||
subfolder: '',
|
||||
error: 'File size 200MB exceeds maximum 100MB',
|
||||
response: null
|
||||
})
|
||||
|
||||
const file = new File(['x'], 'big.png')
|
||||
Object.defineProperty(file, 'size', {
|
||||
value: 200 * 1024 * 1024,
|
||||
writable: false
|
||||
})
|
||||
|
||||
const result = await Load3dUtils.uploadFile(file, '3d')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
const toastStore = useToastStore()
|
||||
expect(toastStore.addAlert).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows generic alert toast on other upload failures', async () => {
|
||||
vi.mocked(uploadMedia).mockResolvedValue({
|
||||
success: false,
|
||||
path: '',
|
||||
name: '',
|
||||
subfolder: '',
|
||||
error: 'Network error',
|
||||
response: null
|
||||
})
|
||||
|
||||
const result = await Load3dUtils.uploadFile(
|
||||
new File(['x'], 'fail.png'),
|
||||
'3d'
|
||||
)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
const toastStore = useToastStore()
|
||||
expect(toastStore.addAlert).toHaveBeenCalledWith('Network error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3dUtils.uploadTempImage', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns the upload response on success', async () => {
|
||||
const response = { name: 'thumb_123.png', subfolder: 'threed' }
|
||||
vi.mocked(uploadMedia).mockResolvedValue({
|
||||
success: true,
|
||||
path: 'threed/thumb_123.png',
|
||||
name: 'thumb_123.png',
|
||||
subfolder: 'threed',
|
||||
response
|
||||
})
|
||||
|
||||
const result = await Load3dUtils.uploadTempImage(
|
||||
'data:image/png;base64,abc',
|
||||
'thumb'
|
||||
)
|
||||
|
||||
expect(result).toEqual(response)
|
||||
expect(uploadMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: 'data:image/png;base64,abc' }),
|
||||
{ subfolder: 'threed', type: 'temp' }
|
||||
)
|
||||
})
|
||||
|
||||
it('throws and shows alert on failure', async () => {
|
||||
vi.mocked(uploadMedia).mockResolvedValue({
|
||||
success: false,
|
||||
path: '',
|
||||
name: '',
|
||||
subfolder: '',
|
||||
error: 'boom',
|
||||
response: null
|
||||
})
|
||||
|
||||
await expect(
|
||||
Load3dUtils.uploadTempImage('data:image/png;base64,abc', 'thumb')
|
||||
).rejects.toThrow()
|
||||
|
||||
const toastStore = useToastStore()
|
||||
expect(toastStore.addAlert).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3dUtils.uploadMultipleFiles', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('filters out failed uploads', async () => {
|
||||
vi.mocked(uploadMedia)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
path: '3d/a.png',
|
||||
name: 'a.png',
|
||||
subfolder: '3d',
|
||||
response: { name: 'a.png' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
path: '',
|
||||
name: '',
|
||||
subfolder: '',
|
||||
error: 'failed',
|
||||
response: null
|
||||
})
|
||||
|
||||
const files = [new File(['1'], 'a.png'), new File(['2'], 'b.png')]
|
||||
const fileList = {
|
||||
length: files.length,
|
||||
item: (i: number) => files[i] ?? null,
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const f of files) yield f
|
||||
}
|
||||
} as unknown as FileList
|
||||
|
||||
const results = await Load3dUtils.uploadMultipleFiles(fileList)
|
||||
|
||||
expect(results).toEqual(['3d/a.png'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from '@/i18n'
|
||||
import { uploadMedia } from '@/platform/assets/services/uploadService'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
class Load3dUtils {
|
||||
@@ -9,84 +9,54 @@ class Load3dUtils {
|
||||
prefix: string,
|
||||
fileType: string = 'png'
|
||||
) {
|
||||
const blob = await fetch(imageData).then((r) => r.blob())
|
||||
const name = `${prefix}_${Date.now()}.${fileType}`
|
||||
const file = new File([blob], name, {
|
||||
type: fileType === 'mp4' ? 'video/mp4' : 'image/png'
|
||||
})
|
||||
const filename = `${prefix}_${Date.now()}.${fileType}`
|
||||
const result = await uploadMedia(
|
||||
{ source: imageData, filename },
|
||||
{ subfolder: 'threed', type: 'temp' }
|
||||
)
|
||||
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
body.append('subfolder', 'threed')
|
||||
body.append('type', 'temp')
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
const err = `Error uploading temp file: ${resp.status} - ${resp.statusText}`
|
||||
if (!result.success || !result.response) {
|
||||
const err = t('toastMessages.tempUploadFailed', {
|
||||
error: result.error || ''
|
||||
})
|
||||
useToastStore().addAlert(err)
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
return await resp.json()
|
||||
return result.response
|
||||
}
|
||||
|
||||
static readonly MAX_UPLOAD_SIZE_MB = 100
|
||||
|
||||
static async uploadFile(file: File, subfolder: string) {
|
||||
let uploadPath
|
||||
const result = await uploadMedia(
|
||||
{ source: file },
|
||||
{ subfolder, maxSizeMB: this.MAX_UPLOAD_SIZE_MB }
|
||||
)
|
||||
|
||||
const fileSizeMB = file.size / 1024 / 1024
|
||||
if (fileSizeMB > this.MAX_UPLOAD_SIZE_MB) {
|
||||
const message = t('toastMessages.fileTooLarge', {
|
||||
size: fileSizeMB.toFixed(1),
|
||||
maxSize: this.MAX_UPLOAD_SIZE_MB
|
||||
})
|
||||
console.warn(
|
||||
'[Load3D] uploadFile: file too large',
|
||||
fileSizeMB.toFixed(2),
|
||||
'MB'
|
||||
)
|
||||
useToastStore().addAlert(message)
|
||||
if (!result.success) {
|
||||
if (result.error?.includes('exceeds maximum')) {
|
||||
const fileSizeMB = file.size / 1024 / 1024
|
||||
const message = t('toastMessages.fileTooLarge', {
|
||||
size: fileSizeMB.toFixed(1),
|
||||
maxSize: this.MAX_UPLOAD_SIZE_MB
|
||||
})
|
||||
console.warn(
|
||||
'[Load3D] uploadFile: file too large',
|
||||
fileSizeMB.toFixed(2),
|
||||
'MB'
|
||||
)
|
||||
useToastStore().addAlert(message)
|
||||
} else {
|
||||
console.error('[Load3D] uploadFile: exception', result.error)
|
||||
useToastStore().addAlert(
|
||||
result.error || t('toastMessages.fileUploadFailed')
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
|
||||
body.append('subfolder', subfolder)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status === 200) {
|
||||
const data = await resp.json()
|
||||
let path = data.name
|
||||
|
||||
if (data.subfolder) {
|
||||
path = data.subfolder + '/' + path
|
||||
}
|
||||
|
||||
uploadPath = path
|
||||
} else {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Load3D] uploadFile: exception', error)
|
||||
useToastStore().addAlert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('toastMessages.fileUploadFailed')
|
||||
)
|
||||
}
|
||||
|
||||
return uploadPath
|
||||
return result.path
|
||||
}
|
||||
|
||||
static getFilenameExtension(url: string): string | undefined {
|
||||
@@ -124,12 +94,16 @@ class Load3dUtils {
|
||||
return `/view?${params}`
|
||||
}
|
||||
|
||||
static async uploadMultipleFiles(files: FileList, subfolder: string = '3d') {
|
||||
static async uploadMultipleFiles(
|
||||
files: FileList,
|
||||
subfolder: string = '3d'
|
||||
): Promise<string[]> {
|
||||
const uploadPromises = Array.from(files).map((file) =>
|
||||
this.uploadFile(file, subfolder)
|
||||
)
|
||||
|
||||
await Promise.all(uploadPromises)
|
||||
const results = await Promise.all(uploadPromises)
|
||||
return results.filter((path): path is string => path !== undefined)
|
||||
}
|
||||
|
||||
static mapSceneLightIntensityToHdri(
|
||||
|
||||
@@ -2074,6 +2074,8 @@
|
||||
"pleaseSelectNodesToGroup": "Please select the nodes (or other groups) to create a group for",
|
||||
"emptyCanvas": "Empty canvas",
|
||||
"fileUploadFailed": "File upload failed",
|
||||
"uploadFailed": "Upload failed",
|
||||
"tempUploadFailed": "Error uploading temp file: {error}",
|
||||
"fileTooLarge": "File too large ({size} MB). Maximum supported size is {maxSize} MB",
|
||||
"unableToGetModelFilePath": "Unable to get model file path",
|
||||
"couldNotDetermineFileType": "Could not determine file type",
|
||||
|
||||
253
src/platform/assets/services/uploadService.test.ts
Normal file
253
src/platform/assets/services/uploadService.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { uploadMedia, uploadMediaBatch } from './uploadService'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockResponse(
|
||||
status: number,
|
||||
data?: { name: string; subfolder?: string }
|
||||
) {
|
||||
return {
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
json: vi.fn().mockResolvedValue(data ?? {})
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('uploadService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('uploadMedia', () => {
|
||||
it('uploads File successfully', async () => {
|
||||
const mockFile = new File(['content'], 'test.png', { type: 'image/png' })
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'test.png', subfolder: 'uploads' })
|
||||
)
|
||||
|
||||
const result = await uploadMedia({ source: mockFile })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.path).toBe('uploads/test.png')
|
||||
expect(result.name).toBe('test.png')
|
||||
expect(result.subfolder).toBe('uploads')
|
||||
})
|
||||
|
||||
it('uploads Blob successfully', async () => {
|
||||
const mockBlob = new Blob(['content'], { type: 'image/png' })
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'upload-123.png', subfolder: '' })
|
||||
)
|
||||
|
||||
const result = await uploadMedia({ source: mockBlob })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.path).toBe('upload-123.png')
|
||||
})
|
||||
|
||||
it('uploads dataURL successfully', async () => {
|
||||
const dataURL = 'data:image/png;base64,iVBORw0KGgo='
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({
|
||||
blob: () => Promise.resolve(new Blob(['content']))
|
||||
} as Response)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'upload-456.png', subfolder: '' })
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await uploadMedia({ source: dataURL })
|
||||
expect(result.success).toBe(true)
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid dataURL', async () => {
|
||||
const invalidURL = 'not-a-data-url'
|
||||
|
||||
const result = await uploadMedia({ source: invalidURL })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Invalid data URL')
|
||||
})
|
||||
|
||||
it('includes subfolder in FormData', async () => {
|
||||
const mockFile = new File(['content'], 'test.png')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'test.png' })
|
||||
)
|
||||
|
||||
await uploadMedia(
|
||||
{ source: mockFile },
|
||||
{ subfolder: 'custom', type: 'input' }
|
||||
)
|
||||
|
||||
const formData = vi.mocked(api.fetchApi).mock.calls[0][1]
|
||||
?.body as FormData
|
||||
expect(formData.get('subfolder')).toBe('custom')
|
||||
expect(formData.get('type')).toBe('input')
|
||||
})
|
||||
|
||||
it('validates file size', async () => {
|
||||
// Create a file that reports as 200MB without actually allocating that much memory
|
||||
const largeFile = new File(['content'], 'large.png')
|
||||
Object.defineProperty(largeFile, 'size', {
|
||||
value: 200 * 1024 * 1024,
|
||||
writable: false
|
||||
})
|
||||
|
||||
const result = await uploadMedia(
|
||||
{ source: largeFile },
|
||||
{ maxSizeMB: 100 }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('exceeds maximum')
|
||||
})
|
||||
|
||||
it('handles upload errors', async () => {
|
||||
const mockFile = new File(['content'], 'test.png')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error'
|
||||
} as unknown as Response)
|
||||
|
||||
const result = await uploadMedia({ source: mockFile })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('500 - Internal Server Error')
|
||||
})
|
||||
|
||||
it('handles exceptions', async () => {
|
||||
const mockFile = new File(['content'], 'test.png')
|
||||
|
||||
vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const result = await uploadMedia({ source: mockFile })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Network error')
|
||||
})
|
||||
|
||||
it('includes originalRef for mask uploads', async () => {
|
||||
const mockFile = new File(['content'], 'mask.png')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'mask.png' })
|
||||
)
|
||||
|
||||
const originalRef = {
|
||||
filename: 'original.png',
|
||||
subfolder: 'images',
|
||||
type: 'input'
|
||||
}
|
||||
|
||||
await uploadMedia(
|
||||
{ source: mockFile },
|
||||
{ endpoint: '/upload/mask', originalRef }
|
||||
)
|
||||
|
||||
const formData = vi.mocked(api.fetchApi).mock.calls[0][1]
|
||||
?.body as FormData
|
||||
expect(formData.get('original_ref')).toBe(JSON.stringify(originalRef))
|
||||
})
|
||||
|
||||
it('forwards the configured endpoint to api.fetchApi', async () => {
|
||||
const mockFile = new File(['content'], 'mask.png')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'mask.png' })
|
||||
)
|
||||
|
||||
await uploadMedia({ source: mockFile }, { endpoint: '/upload/mask' })
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/upload/mask',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to /upload/image endpoint when not specified', async () => {
|
||||
const mockFile = new File(['content'], 'test.png')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'test.png' })
|
||||
)
|
||||
|
||||
await uploadMedia({ source: mockFile })
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/upload/image',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
})
|
||||
|
||||
it('converts Blob source to a File preserving mime type and fallback name', async () => {
|
||||
const mockBlob = new Blob(['content'], { type: 'image/png' })
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'upload-123.png', subfolder: '' })
|
||||
)
|
||||
|
||||
await uploadMedia({ source: mockBlob })
|
||||
|
||||
const formData = vi.mocked(api.fetchApi).mock.calls[0][1]
|
||||
?.body as FormData
|
||||
const uploadedFile = formData.get('image')
|
||||
expect(uploadedFile).toBeInstanceOf(File)
|
||||
expect((uploadedFile as File).type).toBe('image/png')
|
||||
expect((uploadedFile as File).name).toMatch(/^upload-\d+\.png$/)
|
||||
})
|
||||
|
||||
it('uses provided filename when converting a Blob', async () => {
|
||||
const mockBlob = new Blob(['content'], { type: 'image/png' })
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'custom.png', subfolder: '' })
|
||||
)
|
||||
|
||||
await uploadMedia({ source: mockBlob, filename: 'custom.png' })
|
||||
|
||||
const formData = vi.mocked(api.fetchApi).mock.calls[0][1]
|
||||
?.body as FormData
|
||||
const uploadedFile = formData.get('image') as File
|
||||
expect(uploadedFile.name).toBe('custom.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadMediaBatch', () => {
|
||||
it('returns empty array for empty input', async () => {
|
||||
const results = await uploadMediaBatch([])
|
||||
|
||||
expect(results).toHaveLength(0)
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uploads multiple files', async () => {
|
||||
const mockFiles = [
|
||||
new File(['1'], 'file1.png'),
|
||||
new File(['2'], 'file2.png')
|
||||
]
|
||||
|
||||
vi.mocked(api.fetchApi)
|
||||
.mockResolvedValueOnce(
|
||||
createMockResponse(200, { name: 'file1.png', subfolder: '' })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createMockResponse(200, { name: 'file2.png', subfolder: '' })
|
||||
)
|
||||
|
||||
const results = await uploadMediaBatch(
|
||||
mockFiles.map((source) => ({ source }))
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].success).toBe(true)
|
||||
expect(results[1].success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
155
src/platform/assets/services/uploadService.ts
Normal file
155
src/platform/assets/services/uploadService.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ImageRef } from '@/stores/maskEditorDataStore'
|
||||
|
||||
export interface UploadInput {
|
||||
source: File | Blob | string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
export interface UploadConfig {
|
||||
subfolder?: string
|
||||
type?: ResultItemType
|
||||
endpoint?: '/upload/image' | '/upload/mask'
|
||||
originalRef?: ImageRef
|
||||
maxSizeMB?: number
|
||||
}
|
||||
|
||||
interface UploadApiResponse {
|
||||
name: string
|
||||
subfolder?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
success: boolean
|
||||
path: string
|
||||
name: string
|
||||
subfolder: string
|
||||
error?: string
|
||||
response: UploadApiResponse | null
|
||||
}
|
||||
|
||||
function isDataURL(str: string): boolean {
|
||||
return typeof str === 'string' && str.startsWith('data:')
|
||||
}
|
||||
|
||||
async function convertToFile(
|
||||
input: UploadInput,
|
||||
mimeType: string = 'image/png'
|
||||
): Promise<File> {
|
||||
const { source, filename } = input
|
||||
|
||||
if (source instanceof File) {
|
||||
return source
|
||||
}
|
||||
|
||||
if (source instanceof Blob) {
|
||||
const name = filename || `upload-${Date.now()}.png`
|
||||
return new File([source], name, { type: source.type || mimeType })
|
||||
}
|
||||
|
||||
// dataURL string
|
||||
if (!isDataURL(source)) {
|
||||
throw new Error('Invalid data URL')
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await fetch(source).then((r) => r.blob())
|
||||
const name = filename || `upload-${Date.now()}.png`
|
||||
return new File([blob], name, { type: mimeType })
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to convert data URL to file: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function validateFileSize(file: File, maxSizeMB?: number): string | null {
|
||||
if (!maxSizeMB) return null
|
||||
|
||||
const fileSizeMB = file.size / 1024 / 1024
|
||||
if (fileSizeMB > maxSizeMB) {
|
||||
return `File size ${fileSizeMB.toFixed(1)}MB exceeds maximum ${maxSizeMB}MB`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function uploadMedia(
|
||||
input: UploadInput,
|
||||
config: UploadConfig = {}
|
||||
): Promise<UploadResult> {
|
||||
const {
|
||||
subfolder,
|
||||
type,
|
||||
endpoint = '/upload/image',
|
||||
originalRef,
|
||||
maxSizeMB
|
||||
} = config
|
||||
|
||||
try {
|
||||
const file = await convertToFile(input)
|
||||
|
||||
const sizeError = validateFileSize(file, maxSizeMB)
|
||||
if (sizeError) {
|
||||
return {
|
||||
success: false,
|
||||
path: '',
|
||||
name: '',
|
||||
subfolder: '',
|
||||
error: sizeError,
|
||||
response: null
|
||||
}
|
||||
}
|
||||
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (subfolder) body.append('subfolder', subfolder)
|
||||
if (type) body.append('type', type)
|
||||
if (originalRef) body.append('original_ref', JSON.stringify(originalRef))
|
||||
|
||||
const resp = await api.fetchApi(endpoint, {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
path: '',
|
||||
name: '',
|
||||
subfolder: '',
|
||||
error: `${resp.status} - ${resp.statusText}`,
|
||||
response: null
|
||||
}
|
||||
}
|
||||
|
||||
const data: UploadApiResponse = await resp.json()
|
||||
const path = data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path,
|
||||
name: data.name,
|
||||
subfolder: data.subfolder || '',
|
||||
response: data
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
path: '',
|
||||
name: '',
|
||||
subfolder: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
response: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadMediaBatch(
|
||||
inputs: UploadInput[],
|
||||
config: UploadConfig = {}
|
||||
): Promise<UploadResult[]> {
|
||||
return Promise.all(inputs.map((input) => uploadMedia(input, config)))
|
||||
}
|
||||
@@ -88,7 +88,11 @@ const {
|
||||
isAssetMode: () => props.isAssetMode
|
||||
})
|
||||
|
||||
const { updateSelectedItems, handleFilesUpdate } = useWidgetSelectActions({
|
||||
const {
|
||||
updateSelectedItems,
|
||||
handleFilesUpdate,
|
||||
loading: uploading
|
||||
} = useWidgetSelectActions({
|
||||
modelValue,
|
||||
dropdownItems,
|
||||
widget: () => props.widget,
|
||||
@@ -165,6 +169,7 @@ function handleIsOpenUpdate(isOpen: boolean) {
|
||||
:placeholder="mediaPlaceholder"
|
||||
:multiple="false"
|
||||
:uploadable
|
||||
:loading="uploading"
|
||||
:accept="acceptTypes"
|
||||
:filter-options
|
||||
:show-ownership-filter
|
||||
|
||||
@@ -29,6 +29,7 @@ interface Props {
|
||||
multiple?: boolean | number
|
||||
|
||||
uploadable?: boolean
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
accept?: string
|
||||
filterOptions?: FilterOption[]
|
||||
@@ -55,6 +56,7 @@ const {
|
||||
placeholder,
|
||||
multiple = false,
|
||||
uploadable = false,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
accept,
|
||||
filterOptions = [],
|
||||
@@ -141,7 +143,7 @@ function internalIsSelected(item: FormDropdownItem, index: number): boolean {
|
||||
}
|
||||
|
||||
const toggleDropdown = (event: Event) => {
|
||||
if (disabled) return
|
||||
if (disabled || loading) return
|
||||
if (popoverRef.value && triggerRef.value) {
|
||||
popoverRef.value.toggle?.(event, triggerRef.value)
|
||||
isOpen.value = !isOpen.value
|
||||
@@ -156,7 +158,7 @@ const closeDropdown = () => {
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
if (disabled) return
|
||||
if (disabled || loading) return
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
if (target.files) {
|
||||
@@ -200,6 +202,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
:max-selectable
|
||||
:selected
|
||||
:uploadable
|
||||
:loading
|
||||
:disabled
|
||||
:accept
|
||||
@select-click="toggleDropdown"
|
||||
|
||||
@@ -13,11 +13,14 @@ const items: FormDropdownItem[] = [
|
||||
]
|
||||
|
||||
const uploadLabel = 'Upload'
|
||||
const loadingLabel = 'Loading'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { upload: uploadLabel } } }
|
||||
messages: {
|
||||
en: { g: { upload: uploadLabel, loading: loadingLabel } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderInput(
|
||||
@@ -132,4 +135,44 @@ describe('FormDropdownInput', () => {
|
||||
expect(onFileChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading state', () => {
|
||||
it('disables the trigger and file input and sets aria-busy when loading', () => {
|
||||
renderInput({ uploadable: true, loading: true })
|
||||
|
||||
const trigger = screen.getByRole('button')
|
||||
expect(trigger).toBeDisabled()
|
||||
expect(trigger).toHaveAttribute('aria-busy', 'true')
|
||||
|
||||
const fileInput = screen.getByLabelText(uploadLabel)
|
||||
expect(fileInput).toBeDisabled()
|
||||
})
|
||||
|
||||
it('replaces the placeholder with a localized loading label', () => {
|
||||
renderInput({
|
||||
uploadable: true,
|
||||
loading: true,
|
||||
placeholder: 'Pick a file'
|
||||
})
|
||||
|
||||
expect(screen.getByText(`${loadingLabel}...`)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Pick a file')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the trigger and file input enabled when idle', () => {
|
||||
renderInput({ uploadable: true, loading: false })
|
||||
|
||||
const trigger = screen.getByRole('button')
|
||||
expect(trigger).not.toBeDisabled()
|
||||
expect(trigger).not.toHaveAttribute('aria-busy')
|
||||
|
||||
const fileInput = screen.getByLabelText(uploadLabel)
|
||||
expect(fileInput).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps the file input disabled when disabled, regardless of loading', () => {
|
||||
renderInput({ uploadable: true, disabled: true, loading: false })
|
||||
expect(screen.getByLabelText(uploadLabel)).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,25 +16,28 @@ const {
|
||||
maxSelectable,
|
||||
uploadable,
|
||||
disabled,
|
||||
accept
|
||||
accept,
|
||||
loading = false
|
||||
} = defineProps<FormDropdownInputProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-click', event: MouseEvent): void
|
||||
(e: 'file-change', event: Event): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const selectedItems = computed(() => {
|
||||
const itemsToSearch = displayItems ?? items
|
||||
return itemsToSearch.filter((item) => selected.has(item.id))
|
||||
})
|
||||
|
||||
const triggerDisabled = computed(() => disabled || loading)
|
||||
|
||||
const theButtonStyle = computed(() =>
|
||||
cn(
|
||||
'border-0 bg-component-node-widget-background text-text-secondary outline-none',
|
||||
disabled
|
||||
triggerDisabled.value
|
||||
? 'cursor-not-allowed'
|
||||
: 'cursor-pointer hover:bg-component-node-widget-background-hovered',
|
||||
selectedItems.value.length > 0 && 'text-text-primary'
|
||||
@@ -46,7 +49,8 @@ const theButtonStyle = computed(() =>
|
||||
<div
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
|
||||
'cursor-not-allowed opacity-50 outline-node-component-border': disabled
|
||||
'cursor-not-allowed opacity-50 outline-node-component-border':
|
||||
triggerDisabled
|
||||
})
|
||||
"
|
||||
>
|
||||
@@ -61,10 +65,13 @@ const theButtonStyle = computed(() =>
|
||||
}
|
||||
)
|
||||
"
|
||||
:disabled="triggerDisabled"
|
||||
:aria-busy="loading || undefined"
|
||||
@click="emit('select-click', $event)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-2 text-left">
|
||||
<span v-if="!selectedItems.length">
|
||||
<span v-if="loading">{{ t('g.loading') }}...</span>
|
||||
<span v-else-if="!selectedItems.length">
|
||||
{{ placeholder }}
|
||||
</span>
|
||||
<span v-else>
|
||||
@@ -86,18 +93,29 @@ const theButtonStyle = computed(() =>
|
||||
:class="
|
||||
cn(
|
||||
theButtonStyle,
|
||||
'relative',
|
||||
'flex size-8 items-center justify-center rounded-r-lg border-l border-node-component-border'
|
||||
'relative flex size-8 items-center justify-center rounded-r-lg border-l border-node-component-border',
|
||||
loading && 'cursor-wait'
|
||||
)
|
||||
"
|
||||
:aria-busy="loading || undefined"
|
||||
>
|
||||
<i class="icon-[lucide--folder-search] size-4" aria-hidden="true" />
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
loading
|
||||
? 'icon-[lucide--loader-circle] animate-spin'
|
||||
: 'icon-[lucide--folder-search]'
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
class="absolute inset-0 -z-1 opacity-0"
|
||||
:aria-label="t('g.upload')"
|
||||
:multiple="maxSelectable > 1"
|
||||
:disabled="disabled"
|
||||
:disabled="triggerDisabled"
|
||||
:accept="accept"
|
||||
@change="emit('file-change', $event)"
|
||||
/>
|
||||
|
||||
@@ -39,6 +39,12 @@ export interface FormDropdownInputProps {
|
||||
uploadable: boolean
|
||||
disabled: boolean
|
||||
accept?: string
|
||||
/**
|
||||
* When true, indicates an upload is in progress. The trigger swaps its label
|
||||
* to a localized "Loading…" string, the upload icon swaps to a loader-circle,
|
||||
* the underlying file input is disabled, and aria-busy is set.
|
||||
*/
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export interface FormDropdownMenuItemProps {
|
||||
|
||||
@@ -2,11 +2,12 @@ import { toValue } from 'vue'
|
||||
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { UPLOAD_SKIPPED_ERROR, useUpload } from '@/composables/useUpload'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -22,6 +23,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
const { modelValue, dropdownItems } = options
|
||||
const toastStore = useToastStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const { loading, uploadBatch } = useUpload()
|
||||
|
||||
function updateSelectedItems(selectedItems: Set<string>) {
|
||||
const id =
|
||||
@@ -35,47 +37,35 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
file: File,
|
||||
isPasted: boolean = false,
|
||||
formFields: Partial<{ type: ResultItemType }> = {}
|
||||
) {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
else {
|
||||
const subfolder = toValue(options.uploadSubfolder)
|
||||
if (subfolder) body.append('subfolder', subfolder)
|
||||
}
|
||||
if (formFields.type) body.append('type', formFields.type)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
|
||||
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
|
||||
const assetsStore = useAssetsStore()
|
||||
await assetsStore.updateInputs()
|
||||
}
|
||||
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]): Promise<string[]> {
|
||||
const folder = toValue(options.uploadFolder) ?? 'input'
|
||||
const uploadPromises = files.map((file) =>
|
||||
uploadFile(file, false, { type: folder })
|
||||
const subfolder = toValue(options.uploadSubfolder) ?? undefined
|
||||
|
||||
const results = await uploadBatch(
|
||||
files.map((file) => ({ source: file })),
|
||||
{ subfolder, type: folder }
|
||||
)
|
||||
const results = await Promise.all(uploadPromises)
|
||||
return results.filter((path): path is string => path !== null)
|
||||
|
||||
const skipped = results.some((r) => r.error === UPLOAD_SKIPPED_ERROR)
|
||||
if (skipped) {
|
||||
toastStore.addAlert(t('g.uploadAlreadyInProgress'))
|
||||
return []
|
||||
}
|
||||
|
||||
const uploadedPaths: string[] = []
|
||||
for (const result of results) {
|
||||
if (!result.success) {
|
||||
toastStore.addAlert(result.error ?? t('toastMessages.uploadFailed'))
|
||||
continue
|
||||
}
|
||||
uploadedPaths.push(result.path)
|
||||
}
|
||||
|
||||
if (uploadedPaths.length > 0 && folder === 'input') {
|
||||
await useAssetsStore().updateInputs()
|
||||
}
|
||||
|
||||
return uploadedPaths
|
||||
}
|
||||
|
||||
const handleFilesUpdate = wrapWithErrorHandlingAsync(
|
||||
@@ -85,7 +75,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
const uploadedPaths = await uploadFiles(files)
|
||||
|
||||
if (uploadedPaths.length === 0) {
|
||||
toastStore.addAlert('File upload failed')
|
||||
toastStore.addAlert(t('toastMessages.fileUploadFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,6 +101,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
|
||||
return {
|
||||
updateSelectedItems,
|
||||
handleFilesUpdate
|
||||
handleFilesUpdate,
|
||||
loading
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user