mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
Asset Browser Design Review + Filters (#5737)
## Summary Fixed design feedback and wired up the filter controls. ## Review Focus Design Feedback: - [4872888](48728881af) - [9a0b63e](9a0b63edce) Filters Hookup: - [07f22f8](07f22f8074) Misc (can focus less on): - claude guidance: [23e6fa9](23e6fa9723) - test helpers: [7801ed9](7801ed9e28) ## Screenshots (if applicable) <img width="1534" height="1175" alt="Screenshot 2025-09-23 at 1 03 12 PM" src="https://github.com/user-attachments/assets/d82088e4-7d72-4c6f-904e-5180774d64a5" /> <img width="1794" height="793" alt="Screenshot 2025-09-23 at 1 03 22 PM" src="https://github.com/user-attachments/assets/56eac2ba-5ecc-4a20-843f-ce683dea668c" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5737-Asset-Browser-Design-Review-Filters-2776d73d3650813e890bd16fa6a0433f) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -127,7 +127,7 @@ const toggleRightPanel = () => {
|
||||
const layoutClasses = cn(
|
||||
'base-widget-layout',
|
||||
'rounded-2xl overflow-hidden relative',
|
||||
'bg-zinc-50 dark-theme:bg-zinc-800'
|
||||
'bg-gray-50 dark-theme:bg-gray-800'
|
||||
)
|
||||
|
||||
const rightPanelButtonClasses = computed(() => {
|
||||
@@ -144,7 +144,7 @@ const closeButtonClasses = cn(
|
||||
|
||||
const mainContainerClasses = cn(
|
||||
'flex-1 flex',
|
||||
'bg-zinc-100 dark-theme:bg-neutral-900'
|
||||
'bg-gray-100 dark-theme:bg-neutral-900'
|
||||
)
|
||||
|
||||
const headerClasses = cn(
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
class="flex items-center gap-2 px-4 py-3 text-sm rounded-md transition-colors cursor-pointer"
|
||||
:class="
|
||||
active
|
||||
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'
|
||||
: 'text-neutral hover:bg-zinc-100 dark-theme:hover:bg-zinc-700/50'
|
||||
? 'bg-white dark-theme:bg-charcoal-600 text-neutral'
|
||||
: 'text-neutral hover:bg-gray-100 dark-theme:hover:bg-charcoal-300'
|
||||
"
|
||||
role="button"
|
||||
@click="onClick"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full w-full bg-white dark-theme:bg-zinc-800">
|
||||
<div class="flex flex-col h-full w-full bg-white dark-theme:bg-charcoal-600">
|
||||
<PanelHeader>
|
||||
<template #icon>
|
||||
<slot name="header-icon"></slot>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full pl-4 pr-6 pb-8 bg-white dark-theme:bg-zinc-800">
|
||||
<div class="w-full h-full pl-4 pr-6 pb-8 bg-white dark-theme:bg-charcoal-600">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -52,7 +52,7 @@ export const Default: Story = {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
showLeftPanel: false
|
||||
showLeftPanel: true
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #contentFilter>
|
||||
<AssetFilterBar :assets="assets" @filter-change="updateFilters" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<AssetGrid
|
||||
:assets="filteredAssets"
|
||||
@@ -42,6 +46,7 @@ import { computed, provide } from 'vue'
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
@@ -70,19 +75,20 @@ const {
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
filteredAssets,
|
||||
selectAssetWithCallback
|
||||
selectAssetWithCallback,
|
||||
updateFilters
|
||||
} = useAssetBrowser(props.assets)
|
||||
|
||||
const shouldShowLeftPanel = computed(() => {
|
||||
return props.showLeftPanel ?? true
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
function handleClose() {
|
||||
props.onClose?.()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleAssetSelectAndEmit = async (asset: AssetDisplayItem) => {
|
||||
async function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
|
||||
emit('asset-select', asset)
|
||||
await selectAssetWithCallback(asset.id, props.onSelect)
|
||||
}
|
||||
|
||||
@@ -8,19 +8,17 @@
|
||||
cn(
|
||||
// Base layout and container styles (always applied)
|
||||
'rounded-xl overflow-hidden transition-all duration-200',
|
||||
interactive && 'group',
|
||||
// Button-specific styles
|
||||
interactive && [
|
||||
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
|
||||
'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600',
|
||||
'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400',
|
||||
'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700',
|
||||
'focus:outline-none focus:transform focus:-translate-y-0.5 focus:shadow-lg focus:shadow-black/10 dark-theme:focus:shadow-black/30'
|
||||
'bg-gray-100 dark-theme:bg-charcoal-800',
|
||||
'hover:bg-gray-200 dark-theme:hover:bg-charcoal-600',
|
||||
'border-none',
|
||||
'focus:outline-solid outline-blue-100 outline-4'
|
||||
],
|
||||
// Div-specific styles
|
||||
!interactive && [
|
||||
'bg-ivory-100 border border-gray-300',
|
||||
'dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600'
|
||||
]
|
||||
!interactive && 'bg-gray-100 dark-theme:bg-charcoal-800'
|
||||
)
|
||||
"
|
||||
@click="interactive && $emit('select', asset)"
|
||||
@@ -32,7 +30,7 @@
|
||||
></div>
|
||||
<AssetBadgeGroup :badges="asset.badges" />
|
||||
</div>
|
||||
<div class="p-4 h-32 flex flex-col justify-between">
|
||||
<div :class="cn('p-4 h-32 flex flex-col justify-between')">
|
||||
<div>
|
||||
<h3
|
||||
:class="
|
||||
@@ -49,8 +47,8 @@
|
||||
:class="
|
||||
cn(
|
||||
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
|
||||
'text-stone-300',
|
||||
'dark-theme:text-stone-200'
|
||||
'text-stone-100',
|
||||
'dark-theme:text-slate-100'
|
||||
)
|
||||
"
|
||||
:title="asset.description"
|
||||
|
||||
135
src/platform/assets/components/AssetFilterBar.stories.ts
Normal file
135
src/platform/assets/components/AssetFilterBar.stories.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import {
|
||||
createAssetWithSpecificBaseModel,
|
||||
createAssetWithSpecificExtension,
|
||||
createAssetWithoutBaseModel,
|
||||
createAssetWithoutExtension
|
||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
import AssetFilterBar from './AssetFilterBar.vue'
|
||||
|
||||
const meta: Meta<typeof AssetFilterBar> = {
|
||||
title: 'Platform/Assets/AssetFilterBar',
|
||||
component: AssetFilterBar,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Filter bar for asset browser that dynamically shows/hides filters based on available options.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div class="min-h-screen bg-white dark-theme:bg-charcoal-900">
|
||||
<div class="bg-gray-50 dark-theme:bg-charcoal-800 border-b border-gray-200 dark-theme:border-charcoal-600">
|
||||
<story />
|
||||
</div>
|
||||
<div class="p-6 text-sm text-gray-600 dark-theme:text-gray-400">
|
||||
<p>Filter bar with proper chrome styling showing contextual background and borders.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
assets: {
|
||||
description: 'Array of assets to generate filter options from'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const BothFiltersVisible: Story = {
|
||||
args: {
|
||||
assets: [
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificExtension('ckpt'),
|
||||
createAssetWithSpecificBaseModel('sd15'),
|
||||
createAssetWithSpecificBaseModel('sdxl')
|
||||
]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows both file format and base model filters when assets contain both types of options.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const OnlyFileFormatFilter: Story = {
|
||||
args: {
|
||||
assets: [
|
||||
// Assets with extensions but explicitly NO base models
|
||||
{
|
||||
...createAssetWithSpecificExtension('safetensors'),
|
||||
user_metadata: undefined
|
||||
},
|
||||
{ ...createAssetWithSpecificExtension('ckpt'), user_metadata: undefined }
|
||||
]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows only file format filter when assets have file extensions but no base model metadata.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const OnlyBaseModelFilter: Story = {
|
||||
args: {
|
||||
assets: [
|
||||
// Assets with base models but no recognizable extensions
|
||||
{
|
||||
...createAssetWithSpecificBaseModel('sd15'),
|
||||
name: 'model_without_extension'
|
||||
},
|
||||
{ ...createAssetWithSpecificBaseModel('sdxl'), name: 'another_model' }
|
||||
]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows only base model filter when assets have base model metadata but no recognizable file extensions.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NoFiltersVisible: Story = {
|
||||
args: {
|
||||
assets: []
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows no filters when no assets are provided or assets contain no filterable options.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NoFiltersFromAssetsWithoutOptions: Story = {
|
||||
args: {
|
||||
assets: [createAssetWithoutExtension(), createAssetWithoutBaseModel()]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows no filters when assets are provided but contain no filterable options (no extensions or base models).'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,20 @@
|
||||
<div :class="containerClasses" data-component-id="asset-filter-bar">
|
||||
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
|
||||
<MultiSelect
|
||||
v-if="availableFileFormats.length > 0"
|
||||
v-model="fileFormats"
|
||||
:label="$t('assetBrowser.fileFormats')"
|
||||
:options="fileFormatOptions"
|
||||
:options="availableFileFormats"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-file-formats"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-if="availableBaseModels.length > 0"
|
||||
v-model="baseModels"
|
||||
:label="$t('assetBrowser.baseModels')"
|
||||
:options="baseModelOptions"
|
||||
:options="availableBaseModels"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-base-models"
|
||||
@update:model-value="handleFilterChange"
|
||||
@@ -44,6 +46,8 @@ import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
export interface FilterState {
|
||||
@@ -52,25 +56,16 @@ export interface FilterState {
|
||||
sortBy: string
|
||||
}
|
||||
|
||||
const { assets = [] } = defineProps<{
|
||||
assets?: AssetItem[]
|
||||
}>()
|
||||
|
||||
const fileFormats = ref<SelectOption[]>([])
|
||||
const baseModels = ref<SelectOption[]>([])
|
||||
const sortBy = ref('name-asc')
|
||||
|
||||
// TODO: Make fileFormatOptions configurable via props or assetService
|
||||
// Should support dynamic file formats based on available assets or server capabilities
|
||||
const fileFormatOptions = [
|
||||
{ name: '.ckpt', value: 'ckpt' },
|
||||
{ name: '.safetensors', value: 'safetensors' },
|
||||
{ name: '.pt', value: 'pt' }
|
||||
]
|
||||
|
||||
// TODO: Make baseModelOptions configurable via props or assetService
|
||||
// Should support dynamic base models based on available assets or server detection
|
||||
const baseModelOptions = [
|
||||
{ name: 'SD 1.5', value: 'sd15' },
|
||||
{ name: 'SD XL', value: 'sdxl' },
|
||||
{ name: 'SD 3.5', value: 'sd35' }
|
||||
]
|
||||
const { availableFileFormats, availableBaseModels } =
|
||||
useAssetFilterOptions(assets)
|
||||
|
||||
// TODO: Make sortOptions configurable via props
|
||||
// Different asset types might need different sorting options
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { d, t } from '@/i18n'
|
||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetFilenameSchema } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
@@ -10,6 +11,43 @@ import {
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
function filterByCategory(category: string) {
|
||||
return (asset: AssetItem) => {
|
||||
return category === 'all' || asset.tags.includes(category)
|
||||
}
|
||||
}
|
||||
|
||||
function filterByQuery(query: string) {
|
||||
return (asset: AssetItem) => {
|
||||
if (!query) return true
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const description = getAssetDescription(asset)
|
||||
return (
|
||||
asset.name.toLowerCase().includes(lowerQuery) ||
|
||||
(description && description.toLowerCase().includes(lowerQuery)) ||
|
||||
asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function filterByFileFormats(formats: string[]) {
|
||||
return (asset: AssetItem) => {
|
||||
if (formats.length === 0) return true
|
||||
const formatSet = new Set(formats)
|
||||
const extension = asset.name.split('.').pop()?.toLowerCase()
|
||||
return extension ? formatSet.has(extension) : false
|
||||
}
|
||||
}
|
||||
|
||||
function filterByBaseModels(models: string[]) {
|
||||
return (asset: AssetItem) => {
|
||||
if (models.length === 0) return true
|
||||
const modelSet = new Set(models)
|
||||
const baseModel = getAssetBaseModel(asset)
|
||||
return baseModel ? modelSet.has(baseModel) : false
|
||||
}
|
||||
}
|
||||
|
||||
type AssetBadge = {
|
||||
label: string
|
||||
type: 'type' | 'base' | 'size'
|
||||
@@ -35,7 +73,11 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
// State
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('all')
|
||||
const sortBy = ref('name')
|
||||
const filters = ref<FilterState>({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: []
|
||||
})
|
||||
|
||||
// Transform API asset to display asset
|
||||
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
|
||||
@@ -84,16 +126,18 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract available categories from assets
|
||||
const availableCategories = computed(() => {
|
||||
const categorySet = new Set<string>()
|
||||
const categories = assets
|
||||
.filter((asset) => asset.tags[0] === 'models' && asset.tags[1])
|
||||
.map((asset) => asset.tags[1])
|
||||
|
||||
assets.forEach((asset) => {
|
||||
// Second tag is the category (after 'models' root tag)
|
||||
if (asset.tags.length > 1 && asset.tags[0] === 'models') {
|
||||
categorySet.add(asset.tags[1])
|
||||
}
|
||||
})
|
||||
const uniqueCategories = Array.from(new Set(categories))
|
||||
.sort()
|
||||
.map((category) => ({
|
||||
id: category,
|
||||
label: category.charAt(0).toUpperCase() + category.slice(1),
|
||||
icon: 'icon-[lucide--package]'
|
||||
}))
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -101,13 +145,7 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
label: t('assetBrowser.allModels'),
|
||||
icon: 'icon-[lucide--folder]'
|
||||
},
|
||||
...Array.from(categorySet)
|
||||
.sort()
|
||||
.map((category) => ({
|
||||
id: category,
|
||||
label: category.charAt(0).toUpperCase() + category.slice(1),
|
||||
icon: 'icon-[lucide--package]'
|
||||
}))
|
||||
...uniqueCategories
|
||||
]
|
||||
})
|
||||
|
||||
@@ -123,37 +161,25 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
return category?.label || t('assetBrowser.assets')
|
||||
})
|
||||
|
||||
// Filter functions
|
||||
const filterByCategory = (category: string) => (asset: AssetItem) => {
|
||||
if (category === 'all') return true
|
||||
return asset.tags.includes(category)
|
||||
}
|
||||
|
||||
const filterByQuery = (query: string) => (asset: AssetItem) => {
|
||||
if (!query) return true
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const description = getAssetDescription(asset)
|
||||
return (
|
||||
asset.name.toLowerCase().includes(lowerQuery) ||
|
||||
(description && description.toLowerCase().includes(lowerQuery)) ||
|
||||
asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
|
||||
)
|
||||
}
|
||||
|
||||
// Computed filtered and transformed assets
|
||||
const filteredAssets = computed(() => {
|
||||
const filtered = assets
|
||||
.filter(filterByCategory(selectedCategory.value))
|
||||
.filter(filterByQuery(searchQuery.value))
|
||||
.filter(filterByFileFormats(filters.value.fileFormats))
|
||||
.filter(filterByBaseModels(filters.value.baseModels))
|
||||
|
||||
// Sort assets
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortBy.value) {
|
||||
case 'date':
|
||||
switch (filters.value.sortBy) {
|
||||
case 'name-desc':
|
||||
return b.name.localeCompare(a.name)
|
||||
case 'recent':
|
||||
return (
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
case 'name':
|
||||
case 'popular':
|
||||
return a.name.localeCompare(b.name)
|
||||
case 'name-asc':
|
||||
default:
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
@@ -200,18 +226,17 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilters(newFilters: FilterState) {
|
||||
filters.value = { ...newFilters }
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
sortBy,
|
||||
|
||||
// Computed
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
filteredAssets,
|
||||
|
||||
// Actions
|
||||
selectAssetWithCallback
|
||||
selectAssetWithCallback,
|
||||
updateFilters
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,10 @@ export const useAssetBrowserDialog = () => {
|
||||
class: 'rounded-2xl overflow-hidden asset-browser-dialog'
|
||||
},
|
||||
header: {
|
||||
class: 'p-0 hidden'
|
||||
class: '!p-0 hidden'
|
||||
},
|
||||
content: {
|
||||
class: 'p-0 m-0 h-full w-full'
|
||||
class: '!p-0 !m-0 h-full w-full'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,3 +126,34 @@ export function createMockAssets(count: number = 20): AssetItem[] {
|
||||
}
|
||||
|
||||
export const mockAssets = createMockAssets(20)
|
||||
|
||||
// 🧪 Test helpers for edge cases - built on mock asset foundation
|
||||
export function createAssetWithoutExtension() {
|
||||
const asset = createMockAssets(1)[0]
|
||||
asset.name = 'model_no_extension'
|
||||
return asset
|
||||
}
|
||||
|
||||
export function createAssetWithoutBaseModel() {
|
||||
const asset = createMockAssets(1)[0]
|
||||
asset.user_metadata = { description: 'A test model' }
|
||||
return asset
|
||||
}
|
||||
|
||||
export function createAssetWithoutUserMetadata() {
|
||||
const asset = createMockAssets(1)[0]
|
||||
asset.user_metadata = undefined
|
||||
return asset
|
||||
}
|
||||
|
||||
export function createAssetWithSpecificExtension(extension: string) {
|
||||
const asset = createMockAssets(1)[0]
|
||||
asset.name = `test-model.${extension}`
|
||||
return asset
|
||||
}
|
||||
|
||||
export function createAssetWithSpecificBaseModel(baseModel: string) {
|
||||
const asset = createMockAssets(1)[0]
|
||||
asset.user_metadata = { ...asset.user_metadata, base_model: baseModel }
|
||||
return asset
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Unit Testing Guidelines
|
||||
|
||||
## Running Tests
|
||||
- Single file: `pnpm test:unit -- <filename>`
|
||||
- All tests: `pnpm test:unit`
|
||||
- Wrong Examples:
|
||||
- Still runs all tests: `pnpm test:unit <filename>`
|
||||
|
||||
## Testing Approach
|
||||
|
||||
- Write tests for new features
|
||||
@@ -11,3 +17,7 @@
|
||||
- Check @tests-ui/README.md for guidelines
|
||||
- Use existing test utilities
|
||||
- Mock external dependencies
|
||||
|
||||
## Mocking
|
||||
- Read: https://vitest.dev/api/mock.html
|
||||
- Critical: Always prefer vitest mock functions over writing verbose manual mocks
|
||||
|
||||
@@ -7,6 +7,27 @@ import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vu
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
// Mock @/i18n for useAssetBrowser and AssetFilterBar
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => 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'
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock external dependencies with minimal functionality needed for business logic tests
|
||||
vi.mock('@/components/input/SearchBox.vue', () => ({
|
||||
default: {
|
||||
@@ -92,6 +113,11 @@ vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
}),
|
||||
createI18n: () => ({
|
||||
global: {
|
||||
t: (key: string) => key
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -4,6 +4,17 @@ import { nextTick } from 'vue'
|
||||
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import {
|
||||
createAssetWithSpecificBaseModel,
|
||||
createAssetWithSpecificExtension,
|
||||
createAssetWithoutBaseModel
|
||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
// Mock @/i18n directly since component imports { t } from '@/i18n'
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
// Mock components with minimal functionality for business logic testing
|
||||
vi.mock('@/components/input/MultiSelect.vue', () => ({
|
||||
@@ -51,11 +62,26 @@ vi.mock('@/components/input/SingleSelect.vue', () => ({
|
||||
}))
|
||||
|
||||
// Test factory functions
|
||||
function mountAssetFilterBar(props = {}) {
|
||||
return mount(AssetFilterBar, {
|
||||
props,
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('AssetFilterBar', () => {
|
||||
describe('Filter State Management', () => {
|
||||
it('maintains correct initial state', () => {
|
||||
const wrapper = mount(AssetFilterBar)
|
||||
// Provide assets with options so filters are visible
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificBaseModel('sd15')
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
// Test initial state through component props
|
||||
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
|
||||
@@ -67,7 +93,12 @@ describe('AssetFilterBar', () => {
|
||||
})
|
||||
|
||||
it('handles multiple simultaneous filter changes correctly', async () => {
|
||||
const wrapper = mount(AssetFilterBar)
|
||||
// Provide assets with options so filters are visible
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificBaseModel('sd15')
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
// Update file formats
|
||||
const fileFormatSelect = wrapper.findAllComponents({
|
||||
@@ -107,7 +138,12 @@ describe('AssetFilterBar', () => {
|
||||
})
|
||||
|
||||
it('ensures FilterState interface compliance', async () => {
|
||||
const wrapper = mount(AssetFilterBar)
|
||||
// Provide assets with options so filters are visible
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificBaseModel('sd15')
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const fileFormatSelect = wrapper.findAllComponents({
|
||||
name: 'MultiSelect'
|
||||
@@ -135,4 +171,98 @@ describe('AssetFilterBar', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dynamic Filter Options', () => {
|
||||
it('should use dynamic file format options based on actual assets', () => {
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificExtension('ckpt'),
|
||||
createAssetWithSpecificExtension('pt')
|
||||
]
|
||||
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const fileFormatSelect = wrapper.findAllComponents({
|
||||
name: 'MultiSelect'
|
||||
})[0]
|
||||
expect(fileFormatSelect.props('options')).toEqual([
|
||||
{ name: '.ckpt', value: 'ckpt' },
|
||||
{ name: '.pt', value: 'pt' },
|
||||
{ name: '.safetensors', value: 'safetensors' }
|
||||
])
|
||||
})
|
||||
|
||||
it('should use dynamic base model options based on actual assets', () => {
|
||||
const assets = [
|
||||
createAssetWithSpecificBaseModel('sd15'),
|
||||
createAssetWithSpecificBaseModel('sdxl'),
|
||||
createAssetWithSpecificBaseModel('sd35')
|
||||
]
|
||||
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const baseModelSelect = wrapper.findAllComponents({
|
||||
name: 'MultiSelect'
|
||||
})[1]
|
||||
expect(baseModelSelect.props('options')).toEqual([
|
||||
{ name: 'sd15', value: 'sd15' },
|
||||
{ name: 'sd35', value: 'sd35' },
|
||||
{ name: 'sdxl', value: 'sdxl' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Filter Visibility', () => {
|
||||
it('hides file format filter when no options available', () => {
|
||||
const assets: AssetItem[] = [] // No assets = no file format options
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const fileFormatSelects = wrapper
|
||||
.findAllComponents({ name: 'MultiSelect' })
|
||||
.filter(
|
||||
(component) => component.props('label') === 'assetBrowser.fileFormats'
|
||||
)
|
||||
|
||||
expect(fileFormatSelects).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hides base model filter when no options available', () => {
|
||||
const assets = [createAssetWithoutBaseModel()] // Asset without base model = no base model options
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const baseModelSelects = wrapper
|
||||
.findAllComponents({ name: 'MultiSelect' })
|
||||
.filter(
|
||||
(component) => component.props('label') === 'assetBrowser.baseModels'
|
||||
)
|
||||
|
||||
expect(baseModelSelects).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows both filters when options are available', () => {
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificBaseModel('sd15')
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
|
||||
const fileFormatSelect = multiSelects.find(
|
||||
(component) => component.props('label') === 'assetBrowser.fileFormats'
|
||||
)
|
||||
const baseModelSelect = multiSelects.find(
|
||||
(component) => component.props('label') === 'assetBrowser.baseModels'
|
||||
)
|
||||
|
||||
expect(fileFormatSelect).toBeDefined()
|
||||
expect(baseModelSelect).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides both filters when no assets provided', () => {
|
||||
const wrapper = mountAssetFilterBar()
|
||||
|
||||
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
|
||||
expect(multiSelects).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -224,9 +224,9 @@ describe('useAssetBrowser', () => {
|
||||
createApiAsset({ name: 'beta.safetensors' })
|
||||
]
|
||||
|
||||
const { sortBy, filteredAssets } = useAssetBrowser(assets)
|
||||
const { updateFilters, filteredAssets } = useAssetBrowser(assets)
|
||||
|
||||
sortBy.value = 'name'
|
||||
updateFilters({ sortBy: 'name', fileFormats: [], baseModels: [] })
|
||||
await nextTick()
|
||||
|
||||
const names = filteredAssets.value.map((asset) => asset.name)
|
||||
@@ -244,9 +244,9 @@ describe('useAssetBrowser', () => {
|
||||
createApiAsset({ created_at: '2024-02-01T00:00:00Z' })
|
||||
]
|
||||
|
||||
const { sortBy, filteredAssets } = useAssetBrowser(assets)
|
||||
const { updateFilters, filteredAssets } = useAssetBrowser(assets)
|
||||
|
||||
sortBy.value = 'date'
|
||||
updateFilters({ sortBy: 'recent', fileFormats: [], baseModels: [] })
|
||||
await nextTick()
|
||||
|
||||
const dates = filteredAssets.value.map((asset) => asset.created_at)
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
// Test factory functions
|
||||
function createTestAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-uuid',
|
||||
name: 'test-model.safetensors',
|
||||
asset_hash: 'blake3:test123',
|
||||
size: 123456,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
last_access_time: '2024-01-01T00:00:00Z',
|
||||
user_metadata: {
|
||||
base_model: 'sd15'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
import {
|
||||
createAssetWithSpecificBaseModel,
|
||||
createAssetWithSpecificExtension,
|
||||
createAssetWithoutBaseModel,
|
||||
createAssetWithoutExtension,
|
||||
createAssetWithoutUserMetadata
|
||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
describe('useAssetFilterOptions', () => {
|
||||
describe('File Format Extraction', () => {
|
||||
it('extracts file formats from asset names', () => {
|
||||
const assets = [
|
||||
createTestAsset({ name: 'model1.safetensors' }),
|
||||
createTestAsset({ name: 'model2.ckpt' }),
|
||||
createTestAsset({ name: 'model3.pt' })
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificExtension('ckpt'),
|
||||
createAssetWithSpecificExtension('pt')
|
||||
]
|
||||
|
||||
const { availableFileFormats } = useAssetFilterOptions(assets)
|
||||
@@ -42,9 +29,9 @@ describe('useAssetFilterOptions', () => {
|
||||
|
||||
it('handles duplicate file formats', () => {
|
||||
const assets = [
|
||||
createTestAsset({ name: 'model1.safetensors' }),
|
||||
createTestAsset({ name: 'model2.safetensors' }),
|
||||
createTestAsset({ name: 'model3.ckpt' })
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificExtension('ckpt')
|
||||
]
|
||||
|
||||
const { availableFileFormats } = useAssetFilterOptions(assets)
|
||||
@@ -57,8 +44,8 @@ describe('useAssetFilterOptions', () => {
|
||||
|
||||
it('handles assets with no file extension', () => {
|
||||
const assets = [
|
||||
createTestAsset({ name: 'model_no_extension' }),
|
||||
createTestAsset({ name: 'model.safetensors' })
|
||||
createAssetWithoutExtension(),
|
||||
createAssetWithSpecificExtension('safetensors')
|
||||
]
|
||||
|
||||
const { availableFileFormats } = useAssetFilterOptions(assets)
|
||||
@@ -78,9 +65,9 @@ describe('useAssetFilterOptions', () => {
|
||||
describe('Base Model Extraction', () => {
|
||||
it('extracts base models from user metadata', () => {
|
||||
const assets = [
|
||||
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sdxl' } }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sd35' } })
|
||||
createAssetWithSpecificBaseModel('sd15'),
|
||||
createAssetWithSpecificBaseModel('sdxl'),
|
||||
createAssetWithSpecificBaseModel('sd35')
|
||||
]
|
||||
|
||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||
@@ -94,9 +81,9 @@ describe('useAssetFilterOptions', () => {
|
||||
|
||||
it('handles duplicate base models', () => {
|
||||
const assets = [
|
||||
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sdxl' } })
|
||||
createAssetWithSpecificBaseModel('sd15'),
|
||||
createAssetWithSpecificBaseModel('sd15'),
|
||||
createAssetWithSpecificBaseModel('sdxl')
|
||||
]
|
||||
|
||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||
@@ -109,8 +96,8 @@ describe('useAssetFilterOptions', () => {
|
||||
|
||||
it('handles assets with missing user_metadata', () => {
|
||||
const assets = [
|
||||
createTestAsset({ user_metadata: undefined }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sd15' } })
|
||||
createAssetWithoutUserMetadata(),
|
||||
createAssetWithSpecificBaseModel('sd15')
|
||||
]
|
||||
|
||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||
@@ -122,8 +109,8 @@ describe('useAssetFilterOptions', () => {
|
||||
|
||||
it('handles assets with missing base_model field', () => {
|
||||
const assets = [
|
||||
createTestAsset({ user_metadata: { description: 'A test model' } }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sdxl' } })
|
||||
createAssetWithoutBaseModel(),
|
||||
createAssetWithSpecificBaseModel('sdxl')
|
||||
]
|
||||
|
||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||
@@ -142,7 +129,7 @@ describe('useAssetFilterOptions', () => {
|
||||
|
||||
describe('Reactivity', () => {
|
||||
it('returns computed properties that can be reactive', () => {
|
||||
const assets = [createTestAsset({ name: 'model.safetensors' })]
|
||||
const assets = [createAssetWithSpecificExtension('safetensors')]
|
||||
|
||||
const { availableFileFormats, availableBaseModels } =
|
||||
useAssetFilterOptions(assets)
|
||||
|
||||
Reference in New Issue
Block a user