mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-21 14:59:39 +00:00
Full Asset Selection Experience (Assets API) (#5900)
## Summary Full Integration of Asset Browsing and Selection when Assets API is enabled. ## Changes 1. Replace Model Left Side Tab with experience 2. Configurable titles for the Asset Browser Modal 3. Refactors to simplify callback code 4. Refactor to make modal filters reactive (they change their values based on assets displayed) 5. Add `browse()` mode with ability to create node directly from the Asset Browser Modal (in `browse()` mode) ## Screenshots Demo of many different types of Nodes getting configured by the Modal https://github.com/user-attachments/assets/34f9c964-cdf2-4c5d-86a9-a8e7126a7de9 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5900-Feat-asset-selection-cloud-integration-2816d73d365081ccb4aeecdc14b0e5d3) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -1,91 +1,100 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// Mock the dialog store
|
||||
vi.mock('@/stores/dialogStore')
|
||||
|
||||
// Mock the asset service
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetsForNodeType: vi.fn().mockResolvedValue([])
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params) {
|
||||
return `${key}:${JSON.stringify(params)}`
|
||||
}
|
||||
return key
|
||||
}
|
||||
}))
|
||||
|
||||
// Test factory functions
|
||||
interface AssetBrowserProps {
|
||||
nodeType: string
|
||||
inputName: string
|
||||
onAssetSelected?: (filename: string) => void
|
||||
}
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetsForNodeType: vi.fn().mockResolvedValue([]),
|
||||
getAssetsByTag: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}))
|
||||
|
||||
function createAssetBrowserProps(
|
||||
overrides: Partial<AssetBrowserProps> = {}
|
||||
): AssetBrowserProps {
|
||||
const { assetService } = await import('@/platform/assets/services/assetService')
|
||||
const mockGetAssetsByTag = vi.mocked(assetService.getAssetsByTag)
|
||||
const mockGetAssetsForNodeType = vi.mocked(assetService.getAssetsForNodeType)
|
||||
|
||||
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
id: 'asset-123',
|
||||
name: 'test-model.safetensors',
|
||||
size: 1024,
|
||||
created_at: '2025-10-01T00:00:00Z',
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: {
|
||||
filename: 'models/checkpoints/test-model.safetensors'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function setupDialogMocks() {
|
||||
const mockShowDialog = vi.fn()
|
||||
const mockCloseDialog = vi.fn()
|
||||
vi.mocked(useDialogStore, { partial: true }).mockReturnValue({
|
||||
showDialog: mockShowDialog,
|
||||
closeDialog: mockCloseDialog
|
||||
})
|
||||
|
||||
return { mockShowDialog, mockCloseDialog }
|
||||
}
|
||||
|
||||
describe('useAssetBrowserDialog', () => {
|
||||
describe('Asset Selection Flow', () => {
|
||||
it('auto-closes dialog when asset is selected', async () => {
|
||||
// Create fresh mocks for this test
|
||||
const mockShowDialog = vi.fn()
|
||||
const mockCloseDialog = vi.fn()
|
||||
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
showDialog: mockShowDialog,
|
||||
closeDialog: mockCloseDialog
|
||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||
typeof useDialogStore
|
||||
>)
|
||||
|
||||
const { mockShowDialog, mockCloseDialog } = setupDialogMocks()
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
const onAssetSelected = vi.fn()
|
||||
const props = createAssetBrowserProps({ onAssetSelected })
|
||||
|
||||
await assetBrowserDialog.show(props)
|
||||
await assetBrowserDialog.show({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
onAssetSelected
|
||||
})
|
||||
|
||||
// Get the onSelect handler that was passed to the dialog
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
const onSelectHandler = dialogCall.props.onSelect
|
||||
|
||||
// Simulate asset selection
|
||||
onSelectHandler('selected-asset-path')
|
||||
const mockAsset = {
|
||||
id: 'test-asset-id',
|
||||
name: 'test.safetensors',
|
||||
size: 1024,
|
||||
created_at: '2025-10-01T00:00:00Z',
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: { filename: 'selected-asset-path' }
|
||||
}
|
||||
onSelectHandler(mockAsset)
|
||||
|
||||
// Should call the original callback and trigger hide animation
|
||||
expect(onAssetSelected).toHaveBeenCalledWith('selected-asset-path')
|
||||
expect(onAssetSelected).toHaveBeenCalledWith(mockAsset)
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'global-asset-browser'
|
||||
})
|
||||
})
|
||||
|
||||
it('closes dialog when close handler is called', async () => {
|
||||
// Create fresh mocks for this test
|
||||
const mockShowDialog = vi.fn()
|
||||
const mockCloseDialog = vi.fn()
|
||||
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
showDialog: mockShowDialog,
|
||||
closeDialog: mockCloseDialog
|
||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||
typeof useDialogStore
|
||||
>)
|
||||
|
||||
const { mockShowDialog, mockCloseDialog } = setupDialogMocks()
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
const props = createAssetBrowserProps()
|
||||
|
||||
await assetBrowserDialog.show(props)
|
||||
await assetBrowserDialog.show({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name'
|
||||
})
|
||||
|
||||
// Get the onClose handler that was passed to the dialog
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
const onCloseHandler = dialogCall.props.onClose
|
||||
|
||||
// Simulate dialog close
|
||||
onCloseHandler()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
@@ -93,4 +102,158 @@ describe('useAssetBrowserDialog', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('.browse() method', () => {
|
||||
it('opens asset browser dialog with tag-based filtering', async () => {
|
||||
const { mockShowDialog } = setupDialogMocks()
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
await assetBrowserDialog.browse({
|
||||
assetType: 'models',
|
||||
title: 'Model Library'
|
||||
})
|
||||
|
||||
expect(mockShowDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'global-asset-browser',
|
||||
props: expect.objectContaining({
|
||||
showLeftPanel: true
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('calls onAssetSelected callback when asset is selected', async () => {
|
||||
const { mockShowDialog } = setupDialogMocks()
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
const mockAsset = createMockAsset()
|
||||
const onAssetSelected = vi.fn()
|
||||
await assetBrowserDialog.browse({
|
||||
assetType: 'models',
|
||||
onAssetSelected
|
||||
})
|
||||
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
const onSelectHandler = dialogCall.props.onSelect
|
||||
|
||||
onSelectHandler(mockAsset)
|
||||
|
||||
expect(onAssetSelected).toHaveBeenCalledWith(mockAsset)
|
||||
})
|
||||
|
||||
it('closes dialog after asset selection', async () => {
|
||||
const { mockShowDialog, mockCloseDialog } = setupDialogMocks()
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
const mockAsset = createMockAsset()
|
||||
await assetBrowserDialog.browse({
|
||||
assetType: 'models'
|
||||
})
|
||||
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
const onSelectHandler = dialogCall.props.onSelect
|
||||
|
||||
onSelectHandler(mockAsset)
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'global-asset-browser'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses custom title when provided', async () => {
|
||||
const { mockShowDialog } = setupDialogMocks()
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
await assetBrowserDialog.browse({
|
||||
assetType: 'models',
|
||||
title: 'Custom Model Browser'
|
||||
})
|
||||
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
expect(dialogCall.props.title).toBe('Custom Model Browser')
|
||||
})
|
||||
|
||||
it('calls getAssetsByTag with correct assetType parameter', async () => {
|
||||
setupDialogMocks()
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
await assetBrowserDialog.browse({
|
||||
assetType: 'models'
|
||||
})
|
||||
|
||||
expect(mockGetAssetsByTag).toHaveBeenCalledWith('models')
|
||||
})
|
||||
|
||||
it('passes fetched assets to dialog props', async () => {
|
||||
const { mockShowDialog } = setupDialogMocks()
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
const mockAssets = [
|
||||
createMockAsset({ id: 'asset-1', name: 'model1.safetensors' }),
|
||||
createMockAsset({ id: 'asset-2', name: 'model2.safetensors' })
|
||||
]
|
||||
|
||||
mockGetAssetsByTag.mockResolvedValueOnce(mockAssets)
|
||||
await assetBrowserDialog.browse({
|
||||
assetType: 'models'
|
||||
})
|
||||
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
expect(dialogCall.props.assets).toEqual(mockAssets)
|
||||
})
|
||||
|
||||
it('handles asset fetch errors gracefully', async () => {
|
||||
const { mockShowDialog } = setupDialogMocks()
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
mockGetAssetsByTag.mockRejectedValueOnce(new Error('Network error'))
|
||||
await assetBrowserDialog.browse({
|
||||
assetType: 'models'
|
||||
})
|
||||
|
||||
expect(mockShowDialog).toHaveBeenCalled()
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
expect(dialogCall.props.assets).toEqual([])
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to fetch assets for tag:',
|
||||
'models',
|
||||
expect.any(Error)
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('.show() title formatting', () => {
|
||||
it('formats title with VAE acronym uppercase', async () => {
|
||||
const { mockShowDialog } = setupDialogMocks()
|
||||
mockGetAssetsForNodeType.mockResolvedValueOnce([
|
||||
createMockAsset({ tags: ['models', 'vae'] })
|
||||
])
|
||||
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
await assetBrowserDialog.show({
|
||||
nodeType: 'VAELoader',
|
||||
inputName: 'vae_name'
|
||||
})
|
||||
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
expect(dialogCall.props.title).toContain('VAE')
|
||||
})
|
||||
|
||||
it('replaces underscores with spaces in tag names', async () => {
|
||||
const { mockShowDialog } = setupDialogMocks()
|
||||
mockGetAssetsForNodeType.mockResolvedValueOnce([
|
||||
createMockAsset({ tags: ['models', 'style_models'] })
|
||||
])
|
||||
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
await assetBrowserDialog.show({
|
||||
nodeType: 'StyleModelLoader',
|
||||
inputName: 'style_model_name'
|
||||
})
|
||||
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
expect(dialogCall.props.title).toContain('style models')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user