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:
Arjan Singh
2025-10-03 20:34:59 -07:00
committed by GitHub
parent 661885f5e5
commit abf2b3b980
22 changed files with 1452 additions and 554 deletions

View File

@@ -1,10 +1,8 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// Mock @/i18n for useAssetBrowser and AssetFilterBar
@@ -57,6 +55,9 @@ vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
<div data-testid="header">
<slot name="header" />
</div>
<div v-if="$slots.contentFilter" data-testid="content-filter">
<slot name="contentFilter" />
</div>
<div data-testid="content">
<slot name="content" />
</div>
@@ -72,6 +73,9 @@ vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({
emits: ['update:modelValue'],
template: `
<div data-testid="left-side-panel">
<div v-if="$slots['header-title']" data-testid="header-title">
<slot name="header-title" />
</div>
<button
v-for="item in navItems"
:key="item.id"
@@ -86,6 +90,19 @@ vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({
}
}))
vi.mock('@/platform/assets/components/AssetFilterBar.vue', () => ({
default: {
name: 'AssetFilterBar',
props: ['assets'],
emits: ['filter-change'],
template: `
<div data-testid="asset-filter-bar">
Filter bar with {{ assets?.length ?? 0 }} assets
</div>
`
}
}))
vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
default: {
name: 'AssetGrid',
@@ -169,99 +186,33 @@ describe('AssetBrowserModal', () => {
})
}
describe('Search Functionality', () => {
it('filters assets when search query changes', async () => {
describe('Integration with useAssetBrowser', () => {
it('passes filteredAssets from composable to AssetGrid', () => {
const assets = [
createTestAsset('asset1', 'Checkpoint Model A', 'checkpoints'),
createTestAsset('asset2', 'Checkpoint Model B', 'checkpoints'),
createTestAsset('asset3', 'LoRA Model C', 'loras')
createTestAsset('asset1', 'Model A', 'checkpoints'),
createTestAsset('asset2', 'Model B', 'loras')
]
const wrapper = createWrapper(assets)
const searchBox = wrapper.find('[data-testid="search-box"]')
// Search for "Checkpoint"
await searchBox.setValue('Checkpoint')
await nextTick()
// Should filter to only checkpoint assets
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
expect(filteredAssets.length).toBe(2)
expect(
filteredAssets.every((asset: AssetDisplayItem) =>
asset.name.includes('Checkpoint')
)
).toBe(true)
})
it('search is case insensitive', async () => {
const assets = [
createTestAsset('asset1', 'LoRA Model C', 'loras'),
createTestAsset('asset2', 'Checkpoint Model', 'checkpoints')
]
const wrapper = createWrapper(assets)
const searchBox = wrapper.find('[data-testid="search-box"]')
// Search with different case
await searchBox.setValue('lora')
await nextTick()
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
const gridAssets = assetGrid.props('assets')
expect(filteredAssets.length).toBe(1)
expect(filteredAssets[0].name).toContain('LoRA')
expect(gridAssets).toHaveLength(2)
expect(gridAssets[0].id).toBe('asset1')
})
it('shows empty state when search has no results', async () => {
it('passes categoryFilteredAssets to AssetFilterBar', () => {
const assets = [
createTestAsset('asset1', 'Checkpoint Model', 'checkpoints')
]
const wrapper = createWrapper(assets)
const searchBox = wrapper.find('[data-testid="search-box"]')
// Search for something that doesn't exist
await searchBox.setValue('nonexistent')
await nextTick()
expect(wrapper.find('[data-testid="empty-state"]').exists()).toBe(true)
})
})
describe('Category Navigation', () => {
it('filters assets by selected category', async () => {
const assets = [
createTestAsset('asset1', 'Checkpoint Model A', 'checkpoints'),
createTestAsset('asset2', 'LoRA Model C', 'loras'),
createTestAsset('asset3', 'VAE Model D', 'vae')
createTestAsset('c1', 'model.safetensors', 'checkpoints'),
createTestAsset('l1', 'lora.pt', 'loras')
]
const wrapper = createWrapper(assets, { showLeftPanel: true })
// Wait for Vue reactivity and component mounting
await nextTick()
const filterBar = wrapper.findComponent({ name: 'AssetFilterBar' })
const filterBarAssets = filterBar.props('assets')
// Check if left panel exists first (since we have multiple categories)
const leftPanel = wrapper.find('[data-testid="left-panel"]')
expect(leftPanel.exists()).toBe(true)
// Check if the nav item exists before clicking
const lorasNavItem = wrapper.find('[data-testid="nav-item-loras"]')
expect(lorasNavItem.exists()).toBe(true)
// Click the loras category
await lorasNavItem.trigger('click')
await nextTick()
// Should filter to only LoRA assets
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
expect(filteredAssets.length).toBe(1)
expect(filteredAssets[0].name).toContain('LoRA')
// Should initially show all assets
expect(filterBarAssets).toHaveLength(2)
})
})
@@ -277,7 +228,7 @@ describe('AssetBrowserModal', () => {
expect(emitted).toBeDefined()
expect(emitted).toHaveLength(1)
const emittedAsset = emitted![0][0] as AssetDisplayItem
const emittedAsset = emitted![0][0] as AssetItem
expect(emittedAsset.id).toBe('asset1')
})
@@ -289,7 +240,12 @@ describe('AssetBrowserModal', () => {
// Click on first asset
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
expect(onSelectSpy).toHaveBeenCalledWith('Test Model')
expect(onSelectSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: 'asset1',
name: 'Test Model'
})
)
})
})
@@ -327,4 +283,56 @@ describe('AssetBrowserModal', () => {
expect(wrapper2.find('[data-testid="left-panel"]').exists()).toBe(false)
})
})
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')
]
const wrapper = createWrapper(assets, { showLeftPanel: true })
// Initially on "all" category - should have both .safetensors and .pt
const filterBar = wrapper.findComponent({ name: 'AssetFilterBar' })
expect(filterBar.exists()).toBe(true)
// Switch to checkpoints category
const checkpointsNav = wrapper.find(
'[data-testid="nav-item-checkpoints"]'
)
expect(checkpointsNav.exists()).toBe(true)
await checkpointsNav.trigger('click')
// 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)
})
})
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 })
const baseModal = wrapper.findComponent({ name: 'BaseModalLayout' })
expect(baseModal.props('contentTitle')).toBe(customTitle)
})
it('passes computed contentTitle to BaseModalLayout when no title prop', () => {
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
const wrapper = createWrapper(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('')
})
})
})