mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-31 13:29:55 +00:00
load assets browser before fetch completes and show loading state (#6189)
## Summary Moves the fetch and post-fetch logic associated with the asset browser into the component and shows a loading state while fetching. To test, use this branch: https://github.com/comfyanonymous/ComfyUI/pull/10045 https://github.com/user-attachments/assets/718974d5-efc7-46a0-bcd6-e82596d4c389 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6189-load-assets-browser-before-fetch-completes-and-show-loading-state-2946d73d365081879d1bd05d86e8c036) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -40,6 +40,7 @@
|
||||
<template #content>
|
||||
<AssetGrid
|
||||
:assets="filteredAssets"
|
||||
:loading="isLoading"
|
||||
@asset-select="handleAssetSelectAndEmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -47,7 +48,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed, provide, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
@@ -57,6 +60,9 @@ import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -65,10 +71,12 @@ const props = defineProps<{
|
||||
onSelect?: (asset: AssetItem) => void
|
||||
onClose?: () => void
|
||||
showLeftPanel?: boolean
|
||||
assets?: AssetItem[]
|
||||
title?: string
|
||||
assetType?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'asset-select': [asset: AssetDisplayItem]
|
||||
close: []
|
||||
@@ -76,18 +84,73 @@ const emit = defineEmits<{
|
||||
|
||||
provide(OnCloseKey, props.onClose ?? (() => {}))
|
||||
|
||||
const fetchAssets = async () => {
|
||||
if (props.nodeType) {
|
||||
return (await assetService.getAssetsForNodeType(props.nodeType)) ?? []
|
||||
}
|
||||
|
||||
if (props.assetType) {
|
||||
return (await assetService.getAssetsByTag(props.assetType)) ?? []
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const {
|
||||
state: fetchedAssets,
|
||||
isLoading,
|
||||
execute
|
||||
} = useAsyncState<AssetItem[]>(fetchAssets, [], { immediate: false })
|
||||
|
||||
watch(
|
||||
() => [props.nodeType, props.assetType],
|
||||
async () => {
|
||||
await execute()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
categoryFilteredAssets,
|
||||
filteredAssets,
|
||||
updateFilters
|
||||
} = useAssetBrowser(props.assets)
|
||||
} = useAssetBrowser(fetchedAssets)
|
||||
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
|
||||
const primaryCategoryTag = computed(() => {
|
||||
const assets = fetchedAssets.value ?? []
|
||||
const tagFromAssets = assets
|
||||
.map((asset) => asset.tags?.find((tag) => tag !== 'models'))
|
||||
.find((tag): tag is string => typeof tag === 'string' && tag.length > 0)
|
||||
|
||||
if (tagFromAssets) return tagFromAssets
|
||||
|
||||
if (props.nodeType) {
|
||||
const mapped = modelToNodeStore.getCategoryForNodeType(props.nodeType)
|
||||
if (mapped) return mapped
|
||||
}
|
||||
|
||||
if (props.assetType) return props.assetType
|
||||
|
||||
return 'models'
|
||||
})
|
||||
|
||||
const activeCategoryTag = computed(() => {
|
||||
if (selectedCategory.value !== 'all') {
|
||||
return selectedCategory.value
|
||||
}
|
||||
return primaryCategoryTag.value
|
||||
})
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
return props.title ?? contentTitle.value
|
||||
if (props.title) return props.title
|
||||
|
||||
const label = formatCategoryLabel(activeCategoryTag.value)
|
||||
return t('assetBrowser.allCategory', { category: label })
|
||||
})
|
||||
|
||||
const shouldShowLeftPanel = computed(() => {
|
||||
|
||||
@@ -37,12 +37,12 @@
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="col-span-full flex items-center justify-center py-16"
|
||||
class="col-span-full flex items-center justify-center py-20"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader]"
|
||||
:class="
|
||||
cn('size-6 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
|
||||
cn('size-12 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { d, t } from '@/i18n'
|
||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
@@ -65,7 +66,10 @@ export interface AssetDisplayItem extends AssetItem {
|
||||
* Asset Browser composable
|
||||
* Manages search, filtering, asset transformation and selection logic
|
||||
*/
|
||||
export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
export function useAssetBrowser(
|
||||
assetsSource: Ref<AssetItem[] | undefined> = ref<AssetItem[] | undefined>([])
|
||||
) {
|
||||
const assets = computed<AssetItem[]>(() => assetsSource.value ?? [])
|
||||
// State
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('all')
|
||||
@@ -116,9 +120,10 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
}
|
||||
|
||||
const availableCategories = computed(() => {
|
||||
const categories = assets
|
||||
.filter((asset) => asset.tags[0] === 'models' && asset.tags[1])
|
||||
const categories = assets.value
|
||||
.filter((asset) => asset.tags[0] === 'models')
|
||||
.map((asset) => asset.tags[1])
|
||||
.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0)
|
||||
|
||||
const uniqueCategories = Array.from(new Set(categories))
|
||||
.sort()
|
||||
@@ -152,7 +157,7 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
|
||||
// Category-filtered assets for filter options (before search/format/base model filters)
|
||||
const categoryFilteredAssets = computed(() => {
|
||||
return assets.filter(filterByCategory(selectedCategory.value))
|
||||
return assets.value.filter(filterByCategory(selectedCategory.value))
|
||||
})
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
@@ -161,8 +166,8 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
.filter(filterByFileFormats(filters.value.fileFormats))
|
||||
.filter(filterByBaseModels(filters.value.baseModels))
|
||||
|
||||
// Sort assets
|
||||
filtered.sort((a, b) => {
|
||||
const sortedAssets = [...filtered]
|
||||
sortedAssets.sort((a, b) => {
|
||||
switch (filters.value.sortBy) {
|
||||
case 'name-desc':
|
||||
return b.name.localeCompare(a.name)
|
||||
@@ -179,7 +184,7 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
})
|
||||
|
||||
// Transform to display format
|
||||
return filtered.map(transformAssetForDisplay)
|
||||
return sortedAssets.map(transformAssetForDisplay)
|
||||
})
|
||||
|
||||
function updateFilters(newFilters: FilterState) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { t } from '@/i18n'
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
|
||||
@@ -51,33 +49,6 @@ export const useAssetBrowserDialog = () => {
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
}
|
||||
|
||||
const assets: AssetItem[] = await assetService
|
||||
.getAssetsForNodeType(props.nodeType)
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to fetch assets for node type:',
|
||||
props.nodeType,
|
||||
error
|
||||
)
|
||||
return []
|
||||
})
|
||||
|
||||
// Extract node type category from first asset's tags (e.g., "loras", "checkpoints")
|
||||
// Tags are ordered: ["models", "loras"] so take the second tag
|
||||
const nodeTypeCategory =
|
||||
assets[0]?.tags?.find((tag) => tag !== 'models') ?? 'models'
|
||||
|
||||
const acronyms = new Set(['VAE', 'CLIP', 'GLIGEN'])
|
||||
const categoryLabel = nodeTypeCategory
|
||||
.split('_')
|
||||
.map((word) => {
|
||||
const uc = word.toUpperCase()
|
||||
return acronyms.has(uc) ? uc : word
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
const title = t('assetBrowser.allCategory', { category: categoryLabel })
|
||||
|
||||
dialogStore.showDialog({
|
||||
key: dialogKey,
|
||||
component: AssetBrowserModal,
|
||||
@@ -85,8 +56,6 @@ export const useAssetBrowserDialog = () => {
|
||||
nodeType: props.nodeType,
|
||||
inputName: props.inputName,
|
||||
currentValue: props.currentValue,
|
||||
assets,
|
||||
title,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: () => dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
@@ -100,25 +69,12 @@ export const useAssetBrowserDialog = () => {
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
}
|
||||
|
||||
const assets = await assetService
|
||||
.getAssetsByTag(options.assetType)
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to fetch assets for tag:',
|
||||
options.assetType,
|
||||
error
|
||||
)
|
||||
return []
|
||||
})
|
||||
|
||||
dialogStore.showDialog({
|
||||
key: dialogKey,
|
||||
component: AssetBrowserModal,
|
||||
props: {
|
||||
nodeType: undefined,
|
||||
inputName: undefined,
|
||||
assets,
|
||||
showLeftPanel: true,
|
||||
assetType: options.assetType,
|
||||
title: options.title,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: () => dialogStore.closeDialog({ key: dialogKey })
|
||||
|
||||
@@ -4,9 +4,9 @@ import { z } from 'zod'
|
||||
const zAsset = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
asset_hash: z.string().optional(),
|
||||
asset_hash: z.string().nullish(),
|
||||
size: z.number(),
|
||||
mime_type: z.string().optional(),
|
||||
mime_type: z.string().nullish(),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
preview_id: z.string().nullable().optional(),
|
||||
preview_url: z.string().optional(),
|
||||
|
||||
16
src/platform/assets/utils/categoryLabel.ts
Normal file
16
src/platform/assets/utils/categoryLabel.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const ACRONYM_TAGS = new Set(['VAE', 'CLIP', 'GLIGEN'])
|
||||
|
||||
export function formatCategoryLabel(raw?: string): string {
|
||||
if (!raw) return 'Models'
|
||||
|
||||
return raw
|
||||
.split('_')
|
||||
.map((segment) => {
|
||||
const upper = segment.toUpperCase()
|
||||
if (ACRONYM_TAGS.has(upper)) return upper
|
||||
|
||||
const lower = segment.toLowerCase()
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
@@ -1,32 +1,40 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
// Mock @/i18n for useAssetBrowser and AssetFilterBar
|
||||
const mockAssetService = vi.hoisted(() => ({
|
||||
getAssetsForNodeType: vi.fn(),
|
||||
getAssetsByTag: vi.fn(),
|
||||
getAssetDetails: vi.fn((id: string) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
name: 'Test Model',
|
||||
user_metadata: {
|
||||
filename: 'Test Model'
|
||||
}
|
||||
})
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key,
|
||||
t: (key: string, params?: Record<string, string>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key,
|
||||
d: (date: Date) => date.toLocaleDateString()
|
||||
}))
|
||||
|
||||
// Mock assetService for useAssetBrowser
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetDetails: vi.fn((id: string) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
name: 'Test Model',
|
||||
user_metadata: {
|
||||
filename: 'Test Model'
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
assetService: mockAssetService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
getCategoryForNodeType: () => 'checkpoints'
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock external dependencies with minimal functionality needed for business logic tests
|
||||
vi.mock('@/components/input/SearchBox.vue', () => ({
|
||||
default: {
|
||||
name: 'SearchBox',
|
||||
@@ -106,7 +114,7 @@ vi.mock('@/platform/assets/components/AssetFilterBar.vue', () => ({
|
||||
vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
|
||||
default: {
|
||||
name: 'AssetGrid',
|
||||
props: ['assets'],
|
||||
props: ['assets', 'loading'],
|
||||
emits: ['asset-select'],
|
||||
template: `
|
||||
<div data-testid="asset-grid">
|
||||
@@ -129,11 +137,13 @@ vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
t: (key: string, params?: Record<string, string>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key
|
||||
}),
|
||||
createI18n: () => ({
|
||||
global: {
|
||||
t: (key: string) => key
|
||||
t: (key: string, params?: Record<string, string>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key
|
||||
}
|
||||
})
|
||||
}))
|
||||
@@ -160,18 +170,12 @@ describe('AssetBrowserModal', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const createWrapper = (
|
||||
assets: AssetItem[] = [],
|
||||
props: Record<string, unknown> = {}
|
||||
) => {
|
||||
const createWrapper = (props: Record<string, unknown>) => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
return mount(AssetBrowserModal, {
|
||||
props: {
|
||||
assets: assets,
|
||||
...props
|
||||
},
|
||||
props,
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
@@ -186,153 +190,178 @@ describe('AssetBrowserModal', () => {
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAssetService.getAssetsForNodeType.mockReset()
|
||||
mockAssetService.getAssetsByTag.mockReset()
|
||||
})
|
||||
|
||||
describe('Integration with useAssetBrowser', () => {
|
||||
it('passes filteredAssets from composable to AssetGrid', () => {
|
||||
it('passes filtered assets from composable to AssetGrid', async () => {
|
||||
const assets = [
|
||||
createTestAsset('asset1', 'Model A', 'checkpoints'),
|
||||
createTestAsset('asset2', 'Model B', 'loras')
|
||||
]
|
||||
const wrapper = createWrapper(assets)
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
const gridAssets = assetGrid.props('assets')
|
||||
const gridAssets = assetGrid.props('assets') as AssetItem[]
|
||||
|
||||
expect(gridAssets).toHaveLength(2)
|
||||
expect(gridAssets[0].id).toBe('asset1')
|
||||
})
|
||||
|
||||
it('passes categoryFilteredAssets to AssetFilterBar', () => {
|
||||
it('passes category-filtered assets to AssetFilterBar', async () => {
|
||||
const assets = [
|
||||
createTestAsset('c1', 'model.safetensors', 'checkpoints'),
|
||||
createTestAsset('l1', 'lora.pt', 'loras')
|
||||
]
|
||||
const wrapper = createWrapper(assets, { showLeftPanel: true })
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
showLeftPanel: true
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const filterBar = wrapper.findComponent({ name: 'AssetFilterBar' })
|
||||
const filterBarAssets = filterBar.props('assets')
|
||||
const filterBarAssets = filterBar.props('assets') as AssetItem[]
|
||||
|
||||
// Should initially show all assets
|
||||
expect(filterBarAssets).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data fetching', () => {
|
||||
it('fetches assets for node type', async () => {
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
|
||||
|
||||
createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockAssetService.getAssetsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches assets for tag when node type not provided', async () => {
|
||||
mockAssetService.getAssetsByTag.mockResolvedValueOnce([])
|
||||
|
||||
createWrapper({ assetType: 'loras' })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockAssetService.getAssetsByTag).toHaveBeenCalledWith('loras')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Asset Selection', () => {
|
||||
it('emits asset-select event when asset is selected', async () => {
|
||||
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
|
||||
const wrapper = createWrapper(assets)
|
||||
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
|
||||
// Click on first asset
|
||||
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
const emitted = wrapper.emitted('asset-select')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted).toHaveLength(1)
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
await assetGrid.vm.$emit('asset-select', assets[0])
|
||||
|
||||
const emittedAsset = emitted![0][0] as AssetItem
|
||||
expect(emittedAsset.id).toBe('asset1')
|
||||
expect(wrapper.emitted('asset-select')).toEqual([[assets[0]]])
|
||||
})
|
||||
|
||||
it('executes onSelect callback when provided', async () => {
|
||||
const onSelectSpy = vi.fn()
|
||||
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
|
||||
const wrapper = createWrapper(assets, { onSelect: onSelectSpy })
|
||||
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
|
||||
// Click on first asset
|
||||
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
|
||||
const onSelect = vi.fn()
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
onSelect
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(onSelectSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'asset1',
|
||||
name: 'Test Model'
|
||||
})
|
||||
)
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
await assetGrid.vm.$emit('asset-select', assets[0])
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(assets[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Left Panel Conditional Logic', () => {
|
||||
it('hides left panel by default when showLeftPanel prop is undefined', () => {
|
||||
const singleCategoryAssets = [
|
||||
createTestAsset('single1', 'Asset 1', 'checkpoints'),
|
||||
createTestAsset('single2', 'Asset 2', 'checkpoints')
|
||||
]
|
||||
const wrapper = createWrapper(singleCategoryAssets)
|
||||
it('hides left panel by default when showLeftPanel is undefined', async () => {
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
|
||||
|
||||
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(false)
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
const leftPanel = wrapper.find('[data-testid="left-panel"]')
|
||||
expect(leftPanel.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows left panel when showLeftPanel prop is explicitly true', () => {
|
||||
const singleCategoryAssets = [
|
||||
createTestAsset('single1', 'Asset 1', 'checkpoints')
|
||||
]
|
||||
it('shows left panel when showLeftPanel prop is explicitly true', async () => {
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
|
||||
|
||||
// Force show even with single category
|
||||
const wrapper = createWrapper(singleCategoryAssets, {
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
showLeftPanel: true
|
||||
})
|
||||
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(true)
|
||||
await flushPromises()
|
||||
|
||||
// Force hide even with multiple categories
|
||||
wrapper.unmount()
|
||||
const multiCategoryAssets = [
|
||||
createTestAsset('asset1', 'Checkpoint', 'checkpoints'),
|
||||
createTestAsset('asset2', 'LoRA', 'loras')
|
||||
]
|
||||
const wrapper2 = createWrapper(multiCategoryAssets, {
|
||||
showLeftPanel: false
|
||||
})
|
||||
expect(wrapper2.find('[data-testid="left-panel"]').exists()).toBe(false)
|
||||
const leftPanel = wrapper.find('[data-testid="left-panel"]')
|
||||
expect(leftPanel.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filter Options Reactivity', () => {
|
||||
it('updates filter options when category changes', async () => {
|
||||
const assets = [
|
||||
createTestAsset('c1', 'model.safetensors', 'checkpoints'),
|
||||
createTestAsset('c2', 'another.safetensors', 'checkpoints'),
|
||||
createTestAsset('l1', 'lora.pt', 'loras')
|
||||
createTestAsset('asset1', 'Model A', 'checkpoints'),
|
||||
createTestAsset('asset2', 'Model B', 'loras')
|
||||
]
|
||||
const wrapper = createWrapper(assets, { showLeftPanel: true })
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
showLeftPanel: true
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Initially on "all" category - should have both .safetensors and .pt
|
||||
const filterBar = wrapper.findComponent({ name: 'AssetFilterBar' })
|
||||
expect(filterBar.exists()).toBe(true)
|
||||
expect(filterBar.props('assets')).toHaveLength(2)
|
||||
|
||||
// Switch to checkpoints category
|
||||
const checkpointsNav = wrapper.find(
|
||||
'[data-testid="nav-item-checkpoints"]'
|
||||
)
|
||||
expect(checkpointsNav.exists()).toBe(true)
|
||||
await checkpointsNav.trigger('click')
|
||||
const leftPanel = wrapper.findComponent({ name: 'LeftSidePanel' })
|
||||
await leftPanel.vm.$emit('update:modelValue', 'loras')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Filter bar should receive only checkpoint assets now
|
||||
const updatedFilterBar = wrapper.findComponent({ name: 'AssetFilterBar' })
|
||||
const filterBarAssets = updatedFilterBar.props('assets')
|
||||
|
||||
expect(filterBarAssets).toHaveLength(2)
|
||||
expect(
|
||||
filterBarAssets.every((a: AssetItem) => a.tags.includes('checkpoints'))
|
||||
).toBe(true)
|
||||
expect(filterBar.props('assets')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Title Management', () => {
|
||||
it('passes custom title to BaseModalLayout when title prop provided', () => {
|
||||
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
|
||||
const customTitle = 'Model Library'
|
||||
const wrapper = createWrapper(assets, { title: customTitle })
|
||||
it('passes custom title to BaseModalLayout when title prop provided', async () => {
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
|
||||
|
||||
const baseModal = wrapper.findComponent({ name: 'BaseModalLayout' })
|
||||
expect(baseModal.props('contentTitle')).toBe(customTitle)
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
title: 'Custom Title'
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const layout = wrapper.findComponent({ name: 'BaseModalLayout' })
|
||||
expect(layout.props('contentTitle')).toBe('Custom Title')
|
||||
})
|
||||
|
||||
it('passes computed contentTitle to BaseModalLayout when no title prop', () => {
|
||||
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
|
||||
const wrapper = createWrapper(assets)
|
||||
it('passes computed contentTitle to BaseModalLayout when no title prop', async () => {
|
||||
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
|
||||
const baseModal = wrapper.findComponent({ name: 'BaseModalLayout' })
|
||||
// Should use contentTitle from useAssetBrowser (e.g., "All Models")
|
||||
expect(baseModal.props('contentTitle')).toBeTruthy()
|
||||
expect(baseModal.props('contentTitle')).not.toBe('')
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
const layout = wrapper.findComponent({ name: 'BaseModalLayout' })
|
||||
expect(layout.props('contentTitle')).toBe(
|
||||
'assetBrowser.allCategory:{"category":"Checkpoints"}'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
@@ -48,10 +48,9 @@ describe('useAssetBrowser', () => {
|
||||
tags: ['models', 'loras']
|
||||
})
|
||||
|
||||
const { selectedCategory, categoryFilteredAssets } = useAssetBrowser([
|
||||
checkpointAsset,
|
||||
loraAsset
|
||||
])
|
||||
const { selectedCategory, categoryFilteredAssets } = useAssetBrowser(
|
||||
ref([checkpointAsset, loraAsset])
|
||||
)
|
||||
|
||||
// Initially should show all assets
|
||||
expect(categoryFilteredAssets.value).toHaveLength(2)
|
||||
@@ -73,7 +72,7 @@ describe('useAssetBrowser', () => {
|
||||
user_metadata: { description: 'Test model' }
|
||||
})
|
||||
|
||||
const { filteredAssets } = useAssetBrowser([apiAsset])
|
||||
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
|
||||
const result = filteredAssets.value[0] // Get the transformed asset from filteredAssets
|
||||
|
||||
// Preserves API properties
|
||||
@@ -94,7 +93,7 @@ describe('useAssetBrowser', () => {
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
const { filteredAssets } = useAssetBrowser([apiAsset])
|
||||
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
|
||||
const result = filteredAssets.value[0]
|
||||
|
||||
expect(result.description).toBe('loras model')
|
||||
@@ -109,7 +108,7 @@ describe('useAssetBrowser', () => {
|
||||
createApiAsset({ id: '3', tags: ['models', 'checkpoints'] })
|
||||
]
|
||||
|
||||
const { selectedCategory, filteredAssets } = useAssetBrowser(assets)
|
||||
const { selectedCategory, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
selectedCategory.value = 'checkpoints'
|
||||
await nextTick()
|
||||
@@ -128,7 +127,7 @@ describe('useAssetBrowser', () => {
|
||||
createApiAsset({ id: '2', tags: ['models', 'loras'] })
|
||||
]
|
||||
|
||||
const { selectedCategory, filteredAssets } = useAssetBrowser(assets)
|
||||
const { selectedCategory, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
selectedCategory.value = 'all'
|
||||
await nextTick()
|
||||
@@ -145,7 +144,7 @@ describe('useAssetBrowser', () => {
|
||||
createApiAsset({ name: 'photorealistic_v2.safetensors' })
|
||||
]
|
||||
|
||||
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
|
||||
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
searchQuery.value = 'realistic'
|
||||
await nextTick()
|
||||
@@ -170,7 +169,7 @@ describe('useAssetBrowser', () => {
|
||||
})
|
||||
]
|
||||
|
||||
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
|
||||
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
searchQuery.value = 'fantasy'
|
||||
await nextTick()
|
||||
@@ -182,7 +181,7 @@ describe('useAssetBrowser', () => {
|
||||
it('handles empty search results', async () => {
|
||||
const assets = [createApiAsset({ name: 'test.safetensors' })]
|
||||
|
||||
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
|
||||
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
searchQuery.value = 'nonexistent'
|
||||
await nextTick()
|
||||
@@ -208,8 +207,9 @@ describe('useAssetBrowser', () => {
|
||||
})
|
||||
]
|
||||
|
||||
const { searchQuery, selectedCategory, filteredAssets } =
|
||||
useAssetBrowser(assets)
|
||||
const { searchQuery, selectedCategory, filteredAssets } = useAssetBrowser(
|
||||
ref(assets)
|
||||
)
|
||||
|
||||
searchQuery.value = 'realistic'
|
||||
selectedCategory.value = 'checkpoints'
|
||||
@@ -230,7 +230,7 @@ describe('useAssetBrowser', () => {
|
||||
createApiAsset({ name: 'beta.safetensors' })
|
||||
]
|
||||
|
||||
const { updateFilters, filteredAssets } = useAssetBrowser(assets)
|
||||
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
updateFilters({ sortBy: 'name', fileFormats: [], baseModels: [] })
|
||||
await nextTick()
|
||||
@@ -250,7 +250,7 @@ describe('useAssetBrowser', () => {
|
||||
createApiAsset({ created_at: '2024-02-01T00:00:00Z' })
|
||||
]
|
||||
|
||||
const { updateFilters, filteredAssets } = useAssetBrowser(assets)
|
||||
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
updateFilters({ sortBy: 'recent', fileFormats: [], baseModels: [] })
|
||||
await nextTick()
|
||||
@@ -272,7 +272,7 @@ describe('useAssetBrowser', () => {
|
||||
createApiAsset({ tags: ['models', 'checkpoints'] }) // duplicate
|
||||
]
|
||||
|
||||
const { availableCategories } = useAssetBrowser(assets)
|
||||
const { availableCategories } = useAssetBrowser(ref(assets))
|
||||
|
||||
expect(availableCategories.value).toEqual([
|
||||
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
|
||||
@@ -291,7 +291,7 @@ describe('useAssetBrowser', () => {
|
||||
createApiAsset({ tags: ['models', 'vae'] })
|
||||
]
|
||||
|
||||
const { availableCategories } = useAssetBrowser(assets)
|
||||
const { availableCategories } = useAssetBrowser(ref(assets))
|
||||
|
||||
expect(availableCategories.value).toEqual([
|
||||
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
|
||||
@@ -305,7 +305,7 @@ describe('useAssetBrowser', () => {
|
||||
createApiAsset({ tags: ['models', 'checkpoints'] })
|
||||
]
|
||||
|
||||
const { availableCategories } = useAssetBrowser(assets)
|
||||
const { availableCategories } = useAssetBrowser(ref(assets))
|
||||
|
||||
expect(availableCategories.value).toEqual([
|
||||
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
|
||||
@@ -319,7 +319,7 @@ describe('useAssetBrowser', () => {
|
||||
|
||||
it('computes content title from selected category', () => {
|
||||
const assets = [createApiAsset({ tags: ['models', 'checkpoints'] })]
|
||||
const { selectedCategory, contentTitle } = useAssetBrowser(assets)
|
||||
const { selectedCategory, contentTitle } = useAssetBrowser(ref(assets))
|
||||
|
||||
// Default
|
||||
expect(contentTitle.value).toBe('All Models')
|
||||
|
||||
@@ -15,17 +15,6 @@ vi.mock('@/i18n', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetsForNodeType: vi.fn().mockResolvedValue([]),
|
||||
getAssetsByTag: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}))
|
||||
|
||||
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 {
|
||||
id: 'asset-123',
|
||||
@@ -116,7 +105,8 @@ describe('useAssetBrowserDialog', () => {
|
||||
expect.objectContaining({
|
||||
key: 'global-asset-browser',
|
||||
props: expect.objectContaining({
|
||||
showLeftPanel: true
|
||||
showLeftPanel: true,
|
||||
assetType: 'models'
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -169,91 +159,21 @@ describe('useAssetBrowserDialog', () => {
|
||||
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 () => {
|
||||
describe('.show() behavior', () => {
|
||||
it('opens dialog without pre-fetched assets', async () => {
|
||||
const { mockShowDialog } = setupDialogMocks()
|
||||
mockGetAssetsForNodeType.mockResolvedValueOnce([
|
||||
createMockAsset({ tags: ['models', 'vae'] })
|
||||
])
|
||||
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
|
||||
await assetBrowserDialog.show({
|
||||
nodeType: 'VAELoader',
|
||||
inputName: 'vae_name'
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_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')
|
||||
expect(dialogCall.props.nodeType).toBe('CheckpointLoaderSimple')
|
||||
expect(dialogCall.props.assets).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user