mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +00:00
feat: add ownership and base model filtering, unify asset/dropdown types (#8497)
Add ownership and base model filtering to AssetBrowserModal and FormDropdown widgets. ## Changes - **Ownership filter**: Filter by All/My Models/Public Models (uses `is_immutable` field) - **Base model filter**: Multi-select filter with Clear Filters button - **Type unification**: Replace `AssetDropdownItem` with `FormDropdownItem` - **Sorting unification**: Extract shared utilities to `assetSortUtils.ts` - **UI refactor**: Use `Button` component, Vue 3.5 prop shorthand, i18n improvements --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -2504,6 +2504,7 @@
|
|||||||
"imported": "Imported",
|
"imported": "Imported",
|
||||||
"assetCollection": "Asset collection",
|
"assetCollection": "Asset collection",
|
||||||
"assets": "Assets",
|
"assets": "Assets",
|
||||||
|
"baseModel": "Base model",
|
||||||
"baseModels": "Base models",
|
"baseModels": "Base models",
|
||||||
"browseAssets": "Browse Assets",
|
"browseAssets": "Browse Assets",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
@@ -2558,6 +2559,7 @@
|
|||||||
"selectModelType": "Select model type",
|
"selectModelType": "Select model type",
|
||||||
"selectProjects": "Select Projects",
|
"selectProjects": "Select Projects",
|
||||||
"sortAZ": "A-Z",
|
"sortAZ": "A-Z",
|
||||||
|
"sortDefault": "Default",
|
||||||
"sortBy": "Sort by",
|
"sortBy": "Sort by",
|
||||||
"sortingType": "Sorting Type",
|
"sortingType": "Sorting Type",
|
||||||
"sortPopular": "Popular",
|
"sortPopular": "Popular",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
<template #contentFilter>
|
<template #contentFilter>
|
||||||
<AssetFilterBar
|
<AssetFilterBar
|
||||||
:assets="categoryFilteredAssets"
|
:assets="categoryFilteredAssets"
|
||||||
|
:show-ownership-filter
|
||||||
@filter-change="updateFilters"
|
@filter-change="updateFilters"
|
||||||
@click.self="focusedAsset = null"
|
@click.self="focusedAsset = null"
|
||||||
/>
|
/>
|
||||||
@@ -125,19 +126,16 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
provide(OnCloseKey, props.onClose ?? (() => {}))
|
provide(OnCloseKey, props.onClose ?? (() => {}))
|
||||||
|
|
||||||
// Compute the cache key based on nodeType or assetType
|
|
||||||
const cacheKey = computed(() => {
|
const cacheKey = computed(() => {
|
||||||
if (props.nodeType) return props.nodeType
|
if (props.nodeType) return props.nodeType
|
||||||
if (props.assetType) return `tag:${props.assetType}`
|
if (props.assetType) return `tag:${props.assetType}`
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// Read directly from store cache - reactive to any store updates
|
|
||||||
const fetchedAssets = computed(() => assetStore.getAssets(cacheKey.value))
|
const fetchedAssets = computed(() => assetStore.getAssets(cacheKey.value))
|
||||||
|
|
||||||
const isStoreLoading = computed(() => assetStore.isModelLoading(cacheKey.value))
|
const isStoreLoading = computed(() => assetStore.isModelLoading(cacheKey.value))
|
||||||
|
|
||||||
// Only show loading spinner when loading AND no cached data
|
|
||||||
const isLoading = computed(
|
const isLoading = computed(
|
||||||
() => isStoreLoading.value && fetchedAssets.value.length === 0
|
() => isStoreLoading.value && fetchedAssets.value.length === 0
|
||||||
)
|
)
|
||||||
@@ -150,10 +148,8 @@ async function refreshAssets(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger background refresh on mount
|
|
||||||
void refreshAssets()
|
void refreshAssets()
|
||||||
|
|
||||||
// Eagerly fetch model types so they're available when ModelInfoPanel loads
|
|
||||||
const { fetchModelTypes } = useModelTypes()
|
const { fetchModelTypes } = useModelTypes()
|
||||||
void fetchModelTypes()
|
void fetchModelTypes()
|
||||||
|
|
||||||
@@ -210,6 +206,12 @@ const shouldShowLeftPanel = computed(() => {
|
|||||||
return props.showLeftPanel ?? true
|
return props.showLeftPanel ?? true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showOwnershipFilter = computed(
|
||||||
|
() =>
|
||||||
|
!shouldShowLeftPanel.value ||
|
||||||
|
(selectedNavItem.value !== 'all' && selectedNavItem.value !== 'imported')
|
||||||
|
)
|
||||||
|
|
||||||
const emptyMessage = computed(() => {
|
const emptyMessage = computed(() => {
|
||||||
if (!isImportedSelected.value) {
|
if (!isImportedSelected.value) {
|
||||||
return isUploadButtonEnabled.value
|
return isUploadButtonEnabled.value
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
|
|||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
import type { AssetFilterState } from '@/platform/assets/types/filterTypes'
|
||||||
import {
|
import {
|
||||||
createAssetWithSpecificBaseModel,
|
createAssetWithSpecificBaseModel,
|
||||||
createAssetWithSpecificExtension,
|
createAssetWithSpecificExtension,
|
||||||
@@ -142,15 +142,16 @@ describe('AssetFilterBar', () => {
|
|||||||
expect(emitted!.length).toBeGreaterThanOrEqual(3)
|
expect(emitted!.length).toBeGreaterThanOrEqual(3)
|
||||||
|
|
||||||
// Check final state
|
// Check final state
|
||||||
const finalState: FilterState = emitted![
|
const finalState: AssetFilterState = emitted![
|
||||||
emitted!.length - 1
|
emitted!.length - 1
|
||||||
][0] as FilterState
|
][0] as AssetFilterState
|
||||||
expect(finalState.fileFormats).toEqual(['ckpt', 'safetensors'])
|
expect(finalState.fileFormats).toEqual(['ckpt', 'safetensors'])
|
||||||
expect(finalState.baseModels).toEqual(['sdxl'])
|
expect(finalState.baseModels).toEqual(['sdxl'])
|
||||||
expect(finalState.sortBy).toBe('name-desc')
|
expect(finalState.sortBy).toBe('name-desc')
|
||||||
|
expect(finalState.ownership).toBe('all')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ensures FilterState interface compliance', async () => {
|
it('ensures AssetFilterState interface compliance', async () => {
|
||||||
// Provide assets with options so filters are visible
|
// Provide assets with options so filters are visible
|
||||||
const assets = [
|
const assets = [
|
||||||
createAssetWithSpecificExtension('safetensors'),
|
createAssetWithSpecificExtension('safetensors'),
|
||||||
@@ -167,7 +168,7 @@ describe('AssetFilterBar', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const emitted = wrapper.emitted('filterChange')
|
const emitted = wrapper.emitted('filterChange')
|
||||||
const filterState = emitted![0][0] as FilterState
|
const filterState = emitted![0][0] as AssetFilterState
|
||||||
|
|
||||||
// Type and structure assertions
|
// Type and structure assertions
|
||||||
expect(Array.isArray(filterState.fileFormats)).toBe(true)
|
expect(Array.isArray(filterState.fileFormats)).toBe(true)
|
||||||
|
|||||||
@@ -26,6 +26,16 @@
|
|||||||
data-component-id="asset-filter-base-models"
|
data-component-id="asset-filter-base-models"
|
||||||
@update:model-value="handleFilterChange"
|
@update:model-value="handleFilterChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SingleSelect
|
||||||
|
v-if="showOwnershipFilter"
|
||||||
|
v-model="ownership"
|
||||||
|
:label="$t('assetBrowser.ownership')"
|
||||||
|
:options="ownershipOptions"
|
||||||
|
class="min-w-32"
|
||||||
|
data-component-id="asset-filter-ownership"
|
||||||
|
@update:model-value="handleFilterChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center" data-component-id="asset-filter-bar-right">
|
<div class="flex items-center" data-component-id="asset-filter-bar-right">
|
||||||
@@ -54,44 +64,43 @@ import SingleSelect from '@/components/input/SingleSelect.vue'
|
|||||||
import type { SelectOption } from '@/components/input/types'
|
import type { SelectOption } from '@/components/input/types'
|
||||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import type {
|
||||||
|
AssetFilterState,
|
||||||
|
AssetSortOption,
|
||||||
|
OwnershipOption
|
||||||
|
} from '@/platform/assets/types/filterTypes'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
type SortOption = 'recent' | 'name-asc' | 'name-desc'
|
|
||||||
|
|
||||||
const sortOptions = computed(() => [
|
const sortOptions = computed(() => [
|
||||||
{ name: t('assetBrowser.sortRecent'), value: 'recent' as const },
|
{ name: t('assetBrowser.sortRecent'), value: 'recent' as const },
|
||||||
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' as const },
|
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' as const },
|
||||||
{ name: t('assetBrowser.sortZA'), value: 'name-desc' as const }
|
{ name: t('assetBrowser.sortZA'), value: 'name-desc' as const }
|
||||||
])
|
])
|
||||||
|
|
||||||
export interface FilterState {
|
const { assets = [], showOwnershipFilter = false } = defineProps<{
|
||||||
fileFormats: string[]
|
|
||||||
baseModels: string[]
|
|
||||||
sortBy: SortOption
|
|
||||||
}
|
|
||||||
|
|
||||||
const { assets = [] } = defineProps<{
|
|
||||||
assets?: AssetItem[]
|
assets?: AssetItem[]
|
||||||
|
showOwnershipFilter?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const fileFormats = ref<SelectOption[]>([])
|
const fileFormats = ref<SelectOption[]>([])
|
||||||
const baseModels = ref<SelectOption[]>([])
|
const baseModels = ref<SelectOption[]>([])
|
||||||
const sortBy = ref<SortOption>('recent')
|
const sortBy = ref<AssetSortOption>('recent')
|
||||||
|
const ownership = ref<OwnershipOption>('all')
|
||||||
|
|
||||||
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
|
const { availableFileFormats, availableBaseModels, ownershipOptions } =
|
||||||
() => assets
|
useAssetFilterOptions(() => assets)
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
filterChange: [filters: FilterState]
|
filterChange: [filters: AssetFilterState]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function handleFilterChange() {
|
function handleFilterChange() {
|
||||||
emit('filterChange', {
|
emit('filterChange', {
|
||||||
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
||||||
baseModels: baseModels.value.map((option: SelectOption) => option.value),
|
baseModels: baseModels.value.map((option: SelectOption) => option.value),
|
||||||
sortBy: sortBy.value
|
sortBy: sortBy.value,
|
||||||
|
ownership: ownership.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ describe('useAssetBrowser', () => {
|
|||||||
created_at: '2024-01-01T00:00:00Z',
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
updated_at: '2024-01-01T00:00:00Z',
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
last_access_time: '2024-01-01T00:00:00Z',
|
last_access_time: '2024-01-01T00:00:00Z',
|
||||||
|
is_immutable: false,
|
||||||
...overrides
|
...overrides
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -295,7 +296,8 @@ describe('useAssetBrowser', () => {
|
|||||||
updateFilters({
|
updateFilters({
|
||||||
sortBy: 'name-asc',
|
sortBy: 'name-asc',
|
||||||
fileFormats: ['safetensors'],
|
fileFormats: ['safetensors'],
|
||||||
baseModels: []
|
baseModels: [],
|
||||||
|
ownership: 'all'
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
@@ -330,7 +332,8 @@ describe('useAssetBrowser', () => {
|
|||||||
updateFilters({
|
updateFilters({
|
||||||
sortBy: 'name-asc',
|
sortBy: 'name-asc',
|
||||||
fileFormats: [],
|
fileFormats: [],
|
||||||
baseModels: ['SDXL']
|
baseModels: ['SDXL'],
|
||||||
|
ownership: 'all'
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
@@ -384,7 +387,8 @@ describe('useAssetBrowser', () => {
|
|||||||
updateFilters({
|
updateFilters({
|
||||||
sortBy: 'name-asc',
|
sortBy: 'name-asc',
|
||||||
fileFormats: [],
|
fileFormats: [],
|
||||||
baseModels: []
|
baseModels: [],
|
||||||
|
ownership: 'all'
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
@@ -408,7 +412,8 @@ describe('useAssetBrowser', () => {
|
|||||||
updateFilters({
|
updateFilters({
|
||||||
sortBy: 'recent',
|
sortBy: 'recent',
|
||||||
fileFormats: [],
|
fileFormats: [],
|
||||||
baseModels: []
|
baseModels: [],
|
||||||
|
ownership: 'all'
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
@@ -440,7 +445,8 @@ describe('useAssetBrowser', () => {
|
|||||||
updateFilters({
|
updateFilters({
|
||||||
sortBy: 'name-asc',
|
sortBy: 'name-asc',
|
||||||
fileFormats: [],
|
fileFormats: [],
|
||||||
baseModels: []
|
baseModels: [],
|
||||||
|
ownership: 'all'
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
@@ -457,6 +463,12 @@ describe('useAssetBrowser', () => {
|
|||||||
createApiAsset({
|
createApiAsset({
|
||||||
name: 'another-my-model.safetensors',
|
name: 'another-my-model.safetensors',
|
||||||
is_immutable: false
|
is_immutable: false
|
||||||
|
}),
|
||||||
|
// Need a second category so typeCategories.length > 1
|
||||||
|
createApiAsset({
|
||||||
|
name: 'lora.safetensors',
|
||||||
|
is_immutable: true,
|
||||||
|
tags: ['models', 'loras']
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -493,6 +505,127 @@ describe('useAssetBrowser', () => {
|
|||||||
|
|
||||||
expect(filteredAssets.value).toHaveLength(3)
|
expect(filteredAssets.value).toHaveLength(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('filters by ownership via filter bar - my-models', async () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({
|
||||||
|
name: 'my-model.safetensors',
|
||||||
|
is_immutable: false,
|
||||||
|
tags: ['models', 'checkpoints']
|
||||||
|
}),
|
||||||
|
createApiAsset({
|
||||||
|
name: 'public-model.safetensors',
|
||||||
|
is_immutable: true,
|
||||||
|
tags: ['models', 'checkpoints']
|
||||||
|
}),
|
||||||
|
createApiAsset({
|
||||||
|
name: 'another-my-model.safetensors',
|
||||||
|
is_immutable: false,
|
||||||
|
tags: ['models', 'checkpoints']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const { selectedNavItem, updateFilters, filteredAssets } =
|
||||||
|
useAssetBrowser(ref(assets))
|
||||||
|
|
||||||
|
// Must select a specific category for ownership filter to apply
|
||||||
|
selectedNavItem.value = 'checkpoints'
|
||||||
|
updateFilters({
|
||||||
|
sortBy: 'name-asc',
|
||||||
|
fileFormats: [],
|
||||||
|
baseModels: [],
|
||||||
|
ownership: 'my-models'
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(filteredAssets.value).toHaveLength(2)
|
||||||
|
expect(filteredAssets.value.every((asset) => !asset.is_immutable)).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by ownership via filter bar - public-models', async () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({
|
||||||
|
name: 'my-model.safetensors',
|
||||||
|
is_immutable: false,
|
||||||
|
tags: ['models', 'loras']
|
||||||
|
}),
|
||||||
|
createApiAsset({
|
||||||
|
name: 'public-model.safetensors',
|
||||||
|
is_immutable: true,
|
||||||
|
tags: ['models', 'loras']
|
||||||
|
}),
|
||||||
|
createApiAsset({
|
||||||
|
name: 'another-public-model.safetensors',
|
||||||
|
is_immutable: true,
|
||||||
|
tags: ['models', 'loras']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const { selectedNavItem, updateFilters, filteredAssets } =
|
||||||
|
useAssetBrowser(ref(assets))
|
||||||
|
|
||||||
|
// Must select a specific category for ownership filter to apply
|
||||||
|
selectedNavItem.value = 'loras'
|
||||||
|
updateFilters({
|
||||||
|
sortBy: 'name-asc',
|
||||||
|
fileFormats: [],
|
||||||
|
baseModels: [],
|
||||||
|
ownership: 'public-models'
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(filteredAssets.value).toHaveLength(2)
|
||||||
|
expect(filteredAssets.value.every((asset) => asset.is_immutable)).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nav imported selection overrides filter bar ownership', async () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({
|
||||||
|
name: 'my-model.safetensors',
|
||||||
|
is_immutable: false,
|
||||||
|
tags: ['models', 'checkpoints']
|
||||||
|
}),
|
||||||
|
createApiAsset({
|
||||||
|
name: 'public-model.safetensors',
|
||||||
|
is_immutable: true,
|
||||||
|
tags: ['models', 'checkpoints']
|
||||||
|
}),
|
||||||
|
// Need a second category so typeCategories.length > 1
|
||||||
|
createApiAsset({
|
||||||
|
name: 'lora.safetensors',
|
||||||
|
is_immutable: true,
|
||||||
|
tags: ['models', 'loras']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const { selectedNavItem, updateFilters, filteredAssets } =
|
||||||
|
useAssetBrowser(ref(assets))
|
||||||
|
|
||||||
|
// Must select a specific category for ownership filter to apply
|
||||||
|
selectedNavItem.value = 'checkpoints'
|
||||||
|
// Set filter bar to public-models
|
||||||
|
updateFilters({
|
||||||
|
sortBy: 'name-asc',
|
||||||
|
fileFormats: [],
|
||||||
|
baseModels: [],
|
||||||
|
ownership: 'public-models'
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(filteredAssets.value).toHaveLength(1)
|
||||||
|
expect(filteredAssets.value[0].is_immutable).toBe(true)
|
||||||
|
|
||||||
|
// Nav selection to 'imported' should override filter bar
|
||||||
|
selectedNavItem.value = 'imported'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(filteredAssets.value).toHaveLength(1)
|
||||||
|
expect(filteredAssets.value[0].is_immutable).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Dynamic Category Extraction', () => {
|
describe('Dynamic Category Extraction', () => {
|
||||||
|
|||||||
@@ -5,64 +5,27 @@ import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
import { d, t } from '@/i18n'
|
import { d, t } from '@/i18n'
|
||||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
import type {
|
||||||
|
AssetFilterState,
|
||||||
|
OwnershipOption
|
||||||
|
} from '@/platform/assets/types/filterTypes'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import {
|
||||||
|
filterByBaseModels,
|
||||||
|
filterByCategory,
|
||||||
|
filterByFileFormats,
|
||||||
|
filterByOwnership
|
||||||
|
} from '@/platform/assets/utils/assetFilterUtils'
|
||||||
import {
|
import {
|
||||||
getAssetBaseModels,
|
getAssetBaseModels,
|
||||||
getAssetDisplayName,
|
|
||||||
getAssetFilename
|
getAssetFilename
|
||||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
|
import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
|
||||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||||
|
|
||||||
type OwnershipOption = 'all' | 'my-models' | 'public-models'
|
|
||||||
|
|
||||||
type NavId = 'all' | 'imported' | (string & {})
|
type NavId = 'all' | 'imported' | (string & {})
|
||||||
|
|
||||||
function filterByCategory(category: string) {
|
|
||||||
return (asset: AssetItem) => {
|
|
||||||
if (category === 'all') return true
|
|
||||||
|
|
||||||
// Check if any tag matches the category (for exact matches)
|
|
||||||
if (asset.tags.includes(category)) return true
|
|
||||||
|
|
||||||
// Check if any tag's top-level folder matches the category
|
|
||||||
return asset.tags.some((tag) => {
|
|
||||||
if (typeof tag === 'string' && tag.includes('/')) {
|
|
||||||
return tag.split('/')[0] === category
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 assetBaseModels = getAssetBaseModels(asset)
|
|
||||||
return assetBaseModels.some((model) => modelSet.has(model))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterByOwnership(ownership: OwnershipOption) {
|
|
||||||
return (asset: AssetItem) => {
|
|
||||||
if (ownership === 'all') return true
|
|
||||||
if (ownership === 'my-models') return asset.is_immutable === false
|
|
||||||
if (ownership === 'public-models') return asset.is_immutable === true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type AssetBadge = {
|
type AssetBadge = {
|
||||||
label: string
|
label: string
|
||||||
type: 'type' | 'base' | 'size'
|
type: 'type' | 'base' | 'size'
|
||||||
@@ -93,15 +56,18 @@ export function useAssetBrowser(
|
|||||||
// State
|
// State
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const selectedNavItem = ref<NavId>('all')
|
const selectedNavItem = ref<NavId>('all')
|
||||||
const filters = ref<FilterState>({
|
const filters = ref<AssetFilterState>({
|
||||||
sortBy: 'recent',
|
sortBy: 'recent',
|
||||||
fileFormats: [],
|
fileFormats: [],
|
||||||
baseModels: []
|
baseModels: [],
|
||||||
|
ownership: 'all'
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedOwnership = computed<OwnershipOption>(() => {
|
const selectedOwnership = computed<OwnershipOption>(() => {
|
||||||
|
if (typeCategories.value.length <= 1) return filters.value.ownership
|
||||||
if (selectedNavItem.value === 'imported') return 'my-models'
|
if (selectedNavItem.value === 'imported') return 'my-models'
|
||||||
return 'all'
|
if (selectedNavItem.value === 'all') return 'all'
|
||||||
|
return filters.value.ownership
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedCategory = computed(() => {
|
const selectedCategory = computed(() => {
|
||||||
@@ -261,27 +227,13 @@ export function useAssetBrowser(
|
|||||||
.filter(filterByBaseModels(filters.value.baseModels))
|
.filter(filterByBaseModels(filters.value.baseModels))
|
||||||
.filter(filterByOwnership(selectedOwnership.value))
|
.filter(filterByOwnership(selectedOwnership.value))
|
||||||
|
|
||||||
const sortedAssets = [...filtered]
|
const sortedAssets = sortAssets(filtered, filters.value.sortBy)
|
||||||
sortedAssets.sort((a, b) => {
|
|
||||||
switch (filters.value.sortBy) {
|
|
||||||
case 'name-desc':
|
|
||||||
return getAssetDisplayName(b).localeCompare(getAssetDisplayName(a))
|
|
||||||
case 'recent':
|
|
||||||
return (
|
|
||||||
new Date(b.created_at ?? 0).getTime() -
|
|
||||||
new Date(a.created_at ?? 0).getTime()
|
|
||||||
)
|
|
||||||
case 'name-asc':
|
|
||||||
default:
|
|
||||||
return getAssetDisplayName(a).localeCompare(getAssetDisplayName(b))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Transform to display format
|
// Transform to display format
|
||||||
return sortedAssets.map(transformAssetForDisplay)
|
return sortedAssets.map(transformAssetForDisplay)
|
||||||
})
|
})
|
||||||
|
|
||||||
function updateFilters(newFilters: FilterState) {
|
function updateFilters(newFilters: AssetFilterState) {
|
||||||
filters.value = { ...newFilters }
|
filters.value = { ...newFilters }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createAssetWithSpecificBaseModel,
|
createAssetWithSpecificBaseModel,
|
||||||
createAssetWithSpecificExtension,
|
createAssetWithSpecificExtension,
|
||||||
@@ -9,6 +10,12 @@ import {
|
|||||||
createAssetWithoutUserMetadata
|
createAssetWithoutUserMetadata
|
||||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
describe('useAssetFilterOptions', () => {
|
describe('useAssetFilterOptions', () => {
|
||||||
describe('File Format Extraction', () => {
|
describe('File Format Extraction', () => {
|
||||||
it('extracts file formats from asset names', () => {
|
it('extracts file formats from asset names', () => {
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { uniqWith } from 'es-toolkit'
|
import { uniqWith } from 'es-toolkit'
|
||||||
import { computed, toValue } from 'vue'
|
import { computed, toValue } from 'vue'
|
||||||
import type { MaybeRefOrGetter } from 'vue'
|
import type { MaybeRefOrGetter } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import type { SelectOption } from '@/components/input/types'
|
import type { SelectOption } from '@/components/input/types'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import type { OwnershipFilterOption } from '@/platform/assets/types/filterTypes'
|
||||||
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
|
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable that extracts available filter options from asset data
|
* Composable that extracts available filter options from asset data
|
||||||
* Provides reactive computed properties for file formats and base models
|
* Provides reactive computed properties for file formats, base models, and ownership
|
||||||
*/
|
*/
|
||||||
export function useAssetFilterOptions(assets: MaybeRefOrGetter<AssetItem[]>) {
|
export function useAssetFilterOptions(assets: MaybeRefOrGetter<AssetItem[]>) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const ownershipOptions = computed<OwnershipFilterOption[]>(() => [
|
||||||
|
{ name: t('assetBrowser.ownershipAll'), value: 'all' },
|
||||||
|
{ name: t('assetBrowser.ownershipMyModels'), value: 'my-models' },
|
||||||
|
{ name: t('assetBrowser.ownershipPublicModels'), value: 'public-models' }
|
||||||
|
])
|
||||||
/**
|
/**
|
||||||
* Extract unique file formats from asset names
|
* Extract unique file formats from asset names
|
||||||
* Returns sorted SelectOption array with extensions
|
* Returns sorted SelectOption array with extensions
|
||||||
@@ -50,6 +59,7 @@ export function useAssetFilterOptions(assets: MaybeRefOrGetter<AssetItem[]>) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
availableFileFormats,
|
availableFileFormats,
|
||||||
availableBaseModels
|
availableBaseModels,
|
||||||
|
ownershipOptions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/platform/assets/types/filterTypes.ts
Normal file
48
src/platform/assets/types/filterTypes.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Asset filtering and sorting types
|
||||||
|
* Shared across AssetBrowser, AssetFilterBar, and widget dropdowns
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic filter/select option used across components
|
||||||
|
* Compatible with SelectOption (name/value) pattern
|
||||||
|
*/
|
||||||
|
export interface FilterOption {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ownership filter options for assets
|
||||||
|
* - 'all': Show all assets
|
||||||
|
* - 'my-models': Show only user-owned assets (is_immutable === false)
|
||||||
|
* - 'public-models': Show only public assets (is_immutable === true)
|
||||||
|
*/
|
||||||
|
export type OwnershipOption = 'all' | 'my-models' | 'public-models'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ownership filter option for dropdowns/selects
|
||||||
|
*/
|
||||||
|
export interface OwnershipFilterOption {
|
||||||
|
name: string
|
||||||
|
value: OwnershipOption
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort options for asset lists
|
||||||
|
* - 'default': Preserve original order (no sorting)
|
||||||
|
* - 'recent': Sort by created_at descending
|
||||||
|
* - 'name-asc': Sort by display name A-Z
|
||||||
|
* - 'name-desc': Sort by display name Z-A
|
||||||
|
*/
|
||||||
|
export type AssetSortOption = 'default' | 'recent' | 'name-asc' | 'name-desc'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter state for asset browser and filter bar
|
||||||
|
*/
|
||||||
|
export interface AssetFilterState {
|
||||||
|
fileFormats: string[]
|
||||||
|
baseModels: string[]
|
||||||
|
sortBy: AssetSortOption
|
||||||
|
ownership: OwnershipOption
|
||||||
|
}
|
||||||
181
src/platform/assets/utils/assetFilterUtils.test.ts
Normal file
181
src/platform/assets/utils/assetFilterUtils.test.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
import {
|
||||||
|
filterByBaseModels,
|
||||||
|
filterByCategory,
|
||||||
|
filterByFileFormats,
|
||||||
|
filterByOwnership,
|
||||||
|
filterItemByBaseModels,
|
||||||
|
filterItemByOwnership
|
||||||
|
} from './assetFilterUtils'
|
||||||
|
|
||||||
|
function createAsset(
|
||||||
|
name: string,
|
||||||
|
options: Partial<AssetItem> = {}
|
||||||
|
): AssetItem {
|
||||||
|
return {
|
||||||
|
id: `asset-${name}`,
|
||||||
|
name,
|
||||||
|
tags: [],
|
||||||
|
is_immutable: false,
|
||||||
|
...options
|
||||||
|
} satisfies AssetItem
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('filterByCategory', () => {
|
||||||
|
it.for([
|
||||||
|
{ category: 'all', tags: ['checkpoint'], expected: true },
|
||||||
|
{ category: 'checkpoint', tags: ['checkpoint'], expected: true },
|
||||||
|
{ category: 'lora', tags: ['checkpoint'], expected: false },
|
||||||
|
{
|
||||||
|
category: 'checkpoint',
|
||||||
|
tags: ['models', 'checkpoint/xl'],
|
||||||
|
expected: true
|
||||||
|
},
|
||||||
|
{ category: 'xl', tags: ['models', 'checkpoint/xl'], expected: false }
|
||||||
|
])(
|
||||||
|
'category=$category with tags=$tags returns $expected',
|
||||||
|
({ category, tags, expected }) => {
|
||||||
|
const filter = filterByCategory(category)
|
||||||
|
const asset = createAsset('model.safetensors', { tags })
|
||||||
|
expect(filter(asset)).toBe(expected)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('filterByFileFormats', () => {
|
||||||
|
it.for([
|
||||||
|
{ formats: [], name: 'model.safetensors', expected: true },
|
||||||
|
{ formats: ['safetensors'], name: 'model.safetensors', expected: true },
|
||||||
|
{ formats: ['ckpt'], name: 'model.safetensors', expected: false },
|
||||||
|
{ formats: ['safetensors'], name: 'MODEL.SAFETENSORS', expected: true },
|
||||||
|
{ formats: ['safetensors'], name: 'model', expected: false }
|
||||||
|
])(
|
||||||
|
'formats=$formats with name=$name returns $expected',
|
||||||
|
({ formats, name, expected }) => {
|
||||||
|
const filter = filterByFileFormats(formats)
|
||||||
|
const asset = createAsset(name)
|
||||||
|
expect(filter(asset)).toBe(expected)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it('matches any of multiple formats', () => {
|
||||||
|
const filter = filterByFileFormats(['safetensors', 'ckpt', 'bin'])
|
||||||
|
expect(filter(createAsset('model.safetensors'))).toBe(true)
|
||||||
|
expect(filter(createAsset('model.ckpt'))).toBe(true)
|
||||||
|
expect(filter(createAsset('model.bin'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('filterByBaseModels', () => {
|
||||||
|
it.for([
|
||||||
|
{ models: [], expected: true },
|
||||||
|
{ models: new Set<string>(), expected: true }
|
||||||
|
])('empty models ($models) returns true', ({ models }) => {
|
||||||
|
const filter = filterByBaseModels(models)
|
||||||
|
const asset = createAsset('model.safetensors')
|
||||||
|
expect(filter(asset)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.for([
|
||||||
|
{
|
||||||
|
models: ['SDXL'],
|
||||||
|
metadata: { base_model: ['SDXL'] },
|
||||||
|
expected: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
models: ['SDXL'],
|
||||||
|
metadata: { base_model: ['SD1.5'] },
|
||||||
|
expected: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
models: new Set(['SDXL', 'SD1.5']),
|
||||||
|
metadata: { base_model: ['SDXL'] },
|
||||||
|
expected: true
|
||||||
|
}
|
||||||
|
])(
|
||||||
|
'models=$models with metadata.base_model returns $expected',
|
||||||
|
({ models, metadata, expected }) => {
|
||||||
|
const filter = filterByBaseModels(models)
|
||||||
|
const asset = createAsset('model.safetensors', { metadata })
|
||||||
|
expect(filter(asset)).toBe(expected)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it('matches base model in user_metadata', () => {
|
||||||
|
const filter = filterByBaseModels(['SD1.5'])
|
||||||
|
const asset = createAsset('model.safetensors', {
|
||||||
|
user_metadata: { base_model: ['SD1.5'] }
|
||||||
|
})
|
||||||
|
expect(filter(asset)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('filterByOwnership', () => {
|
||||||
|
it.for([
|
||||||
|
{ ownership: 'all' as const, is_immutable: true, expected: true },
|
||||||
|
{ ownership: 'all' as const, is_immutable: false, expected: true },
|
||||||
|
{ ownership: 'my-models' as const, is_immutable: false, expected: true },
|
||||||
|
{ ownership: 'my-models' as const, is_immutable: true, expected: false },
|
||||||
|
{
|
||||||
|
ownership: 'public-models' as const,
|
||||||
|
is_immutable: true,
|
||||||
|
expected: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ownership: 'public-models' as const,
|
||||||
|
is_immutable: false,
|
||||||
|
expected: false
|
||||||
|
}
|
||||||
|
])(
|
||||||
|
'ownership=$ownership with is_immutable=$is_immutable returns $expected',
|
||||||
|
({ ownership, is_immutable, expected }) => {
|
||||||
|
const filter = filterByOwnership(ownership)
|
||||||
|
const asset = createAsset('model', { is_immutable })
|
||||||
|
expect(filter(asset)).toBe(expected)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('filterItemByOwnership', () => {
|
||||||
|
const items = [
|
||||||
|
{ id: '1', is_immutable: true },
|
||||||
|
{ id: '2', is_immutable: false },
|
||||||
|
{ id: '3', is_immutable: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
it.for([
|
||||||
|
{ ownership: 'all' as const, expectedIds: ['1', '2', '3'] },
|
||||||
|
{ ownership: 'my-models' as const, expectedIds: ['2'] },
|
||||||
|
{ ownership: 'public-models' as const, expectedIds: ['1', '3'] }
|
||||||
|
])(
|
||||||
|
'ownership=$ownership returns items with ids=$expectedIds',
|
||||||
|
({ ownership, expectedIds }) => {
|
||||||
|
const result = filterItemByOwnership(items, ownership)
|
||||||
|
expect(result.map((i) => i.id)).toEqual(expectedIds)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('filterItemByBaseModels', () => {
|
||||||
|
const items = [
|
||||||
|
{ id: '1', base_models: ['SDXL'] },
|
||||||
|
{ id: '2', base_models: ['SD1.5'] },
|
||||||
|
{ id: '3', base_models: ['SDXL', 'SD1.5'] },
|
||||||
|
{ id: '4' }
|
||||||
|
]
|
||||||
|
|
||||||
|
it.for([
|
||||||
|
{ selectedModels: new Set<string>(), expectedIds: ['1', '2', '3', '4'] },
|
||||||
|
{ selectedModels: new Set(['SDXL']), expectedIds: ['1', '3'] },
|
||||||
|
{ selectedModels: new Set(['SD1.5']), expectedIds: ['2', '3'] }
|
||||||
|
])(
|
||||||
|
'selectedModels=$selectedModels returns items with ids=$expectedIds',
|
||||||
|
({ selectedModels, expectedIds }) => {
|
||||||
|
const result = filterItemByBaseModels(items, selectedModels)
|
||||||
|
expect(result.map((i) => i.id)).toEqual(expectedIds)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
66
src/platform/assets/utils/assetFilterUtils.ts
Normal file
66
src/platform/assets/utils/assetFilterUtils.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import type { OwnershipOption } from '@/platform/assets/types/filterTypes'
|
||||||
|
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
|
|
||||||
|
export function filterByCategory(category: string) {
|
||||||
|
return (asset: AssetItem) => {
|
||||||
|
if (category === 'all') return true
|
||||||
|
|
||||||
|
// Check if any tag matches the category (for exact matches)
|
||||||
|
if (asset.tags.includes(category)) return true
|
||||||
|
|
||||||
|
// Check if any tag's top-level folder matches the category
|
||||||
|
return asset.tags.some((tag) => {
|
||||||
|
if (typeof tag === 'string' && tag.includes('/')) {
|
||||||
|
return tag.split('/')[0] === category
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterByBaseModels(models: string[] | Set<string>) {
|
||||||
|
const modelSet = models instanceof Set ? models : new Set(models)
|
||||||
|
return (asset: AssetItem) => {
|
||||||
|
if (modelSet.size === 0) return true
|
||||||
|
const assetBaseModels = getAssetBaseModels(asset)
|
||||||
|
return assetBaseModels.some((model) => modelSet.has(model))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterByOwnership(ownership: OwnershipOption) {
|
||||||
|
return (asset: AssetItem) => {
|
||||||
|
if (ownership === 'all') return true
|
||||||
|
if (ownership === 'my-models') return asset.is_immutable === false
|
||||||
|
if (ownership === 'public-models') return asset.is_immutable === true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterItemByOwnership<T extends { is_immutable?: boolean }>(
|
||||||
|
items: T[],
|
||||||
|
ownership: OwnershipOption
|
||||||
|
): T[] {
|
||||||
|
if (ownership === 'all') return items
|
||||||
|
const isPublic = ownership === 'public-models'
|
||||||
|
return items.filter((item) => item.is_immutable === isPublic)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterItemByBaseModels<T extends { base_models?: string[] }>(
|
||||||
|
items: T[],
|
||||||
|
selectedModels: Set<string>
|
||||||
|
): T[] {
|
||||||
|
if (selectedModels.size === 0) return items
|
||||||
|
return items.filter((item) =>
|
||||||
|
item.base_models?.some((model) => selectedModels.has(model))
|
||||||
|
)
|
||||||
|
}
|
||||||
122
src/platform/assets/utils/assetSortUtils.test.ts
Normal file
122
src/platform/assets/utils/assetSortUtils.test.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { SortableItem } from './assetSortUtils'
|
||||||
|
import { sortAssets } from './assetSortUtils'
|
||||||
|
|
||||||
|
function createItem(
|
||||||
|
name: string,
|
||||||
|
options: { label?: string; created_at?: string } = {}
|
||||||
|
): SortableItem {
|
||||||
|
return { name, ...options }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sortAssets', () => {
|
||||||
|
describe('default sort', () => {
|
||||||
|
it('preserves original order', () => {
|
||||||
|
const items = [createItem('z'), createItem('a'), createItem('m')]
|
||||||
|
const result = sortAssets(items, 'default')
|
||||||
|
expect(result.map((i) => i.name)).toEqual(['z', 'a', 'm'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a new array (does not mutate)', () => {
|
||||||
|
const items = [createItem('z'), createItem('a')]
|
||||||
|
const result = sortAssets(items, 'default')
|
||||||
|
expect(result).not.toBe(items)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('name-asc sort', () => {
|
||||||
|
it('sorts alphabetically A-Z by name', () => {
|
||||||
|
const items = [
|
||||||
|
createItem('cherry'),
|
||||||
|
createItem('apple'),
|
||||||
|
createItem('banana')
|
||||||
|
]
|
||||||
|
const result = sortAssets(items, 'name-asc')
|
||||||
|
expect(result.map((i) => i.name)).toEqual(['apple', 'banana', 'cherry'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers label over name when label exists', () => {
|
||||||
|
const items = [
|
||||||
|
createItem('file_c.png', { label: 'Cherry' }),
|
||||||
|
createItem('file_a.png', { label: 'Apple' }),
|
||||||
|
createItem('file_b.png', { label: 'Banana' })
|
||||||
|
]
|
||||||
|
const result = sortAssets(items, 'name-asc')
|
||||||
|
expect(result.map((i) => i.name)).toEqual([
|
||||||
|
'file_a.png',
|
||||||
|
'file_b.png',
|
||||||
|
'file_c.png'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses natural sort for numeric values', () => {
|
||||||
|
const items = [
|
||||||
|
createItem('img_10.png'),
|
||||||
|
createItem('img_2.png'),
|
||||||
|
createItem('img_1.png'),
|
||||||
|
createItem('img_20.png')
|
||||||
|
]
|
||||||
|
const result = sortAssets(items, 'name-asc')
|
||||||
|
expect(result.map((i) => i.name)).toEqual([
|
||||||
|
'img_1.png',
|
||||||
|
'img_2.png',
|
||||||
|
'img_10.png',
|
||||||
|
'img_20.png'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
const items = [
|
||||||
|
createItem('Banana'),
|
||||||
|
createItem('apple'),
|
||||||
|
createItem('CHERRY')
|
||||||
|
]
|
||||||
|
const result = sortAssets(items, 'name-asc')
|
||||||
|
expect(result.map((i) => i.name)).toEqual(['apple', 'Banana', 'CHERRY'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('name-desc sort', () => {
|
||||||
|
it('sorts alphabetically Z-A by name', () => {
|
||||||
|
const items = [
|
||||||
|
createItem('apple'),
|
||||||
|
createItem('cherry'),
|
||||||
|
createItem('banana')
|
||||||
|
]
|
||||||
|
const result = sortAssets(items, 'name-desc')
|
||||||
|
expect(result.map((i) => i.name)).toEqual(['cherry', 'banana', 'apple'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recent sort', () => {
|
||||||
|
it('sorts by created_at descending (newest first)', () => {
|
||||||
|
const items = [
|
||||||
|
createItem('old', { created_at: '2024-01-01T00:00:00Z' }),
|
||||||
|
createItem('newest', { created_at: '2024-03-01T00:00:00Z' }),
|
||||||
|
createItem('middle', { created_at: '2024-02-01T00:00:00Z' })
|
||||||
|
]
|
||||||
|
const result = sortAssets(items, 'recent')
|
||||||
|
expect(result.map((i) => i.name)).toEqual(['newest', 'middle', 'old'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles null/undefined created_at (sorts to end)', () => {
|
||||||
|
const items = [
|
||||||
|
createItem('no-date'),
|
||||||
|
createItem('has-date', { created_at: '2024-01-01T00:00:00Z' }),
|
||||||
|
createItem('undefined-date', { created_at: undefined })
|
||||||
|
]
|
||||||
|
const result = sortAssets(items, 'recent')
|
||||||
|
expect(result[0].name).toBe('has-date')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('immutability', () => {
|
||||||
|
it('does not mutate the original array', () => {
|
||||||
|
const items = [createItem('z'), createItem('a')]
|
||||||
|
const original = [...items]
|
||||||
|
sortAssets(items, 'name-asc')
|
||||||
|
expect(items).toEqual(original)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
61
src/platform/assets/utils/assetSortUtils.ts
Normal file
61
src/platform/assets/utils/assetSortUtils.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Shared asset sorting utilities
|
||||||
|
* Used by both AssetBrowser and FormDropdown
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AssetSortOption } from '../types/filterTypes'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal interface for sortable items
|
||||||
|
* Works with both AssetItem and FormDropdownItem
|
||||||
|
*/
|
||||||
|
export interface SortableItem {
|
||||||
|
name: string
|
||||||
|
label?: string
|
||||||
|
created_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(item: SortableItem): string {
|
||||||
|
return item.label ?? item.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort items by the specified sort option
|
||||||
|
* @param items - Array of sortable items
|
||||||
|
* @param sortBy - Sort option from AssetSortOption
|
||||||
|
* @returns New sorted array (does not mutate input)
|
||||||
|
*/
|
||||||
|
export function sortAssets<T extends SortableItem>(
|
||||||
|
items: readonly T[],
|
||||||
|
sortBy: AssetSortOption
|
||||||
|
): T[] {
|
||||||
|
if (sortBy === 'default') {
|
||||||
|
return items.slice()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = items.slice()
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'name-desc':
|
||||||
|
return sorted.sort((a, b) =>
|
||||||
|
getDisplayName(b).localeCompare(getDisplayName(a), undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
case 'recent':
|
||||||
|
return sorted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.created_at ?? 0).getTime() -
|
||||||
|
new Date(a.created_at ?? 0).getTime()
|
||||||
|
)
|
||||||
|
case 'name-asc':
|
||||||
|
default:
|
||||||
|
return sorted.sort((a, b) =>
|
||||||
|
getDisplayName(a).localeCompare(getDisplayName(b), undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,18 @@ import { createTestingPinia } from '@pinia/testing'
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||||
|
|
||||||
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
|
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: { en: {} }
|
||||||
|
})
|
||||||
|
|
||||||
// Mock modules
|
// Mock modules
|
||||||
vi.mock('@/platform/distribution/types', () => ({
|
vi.mock('@/platform/distribution/types', () => ({
|
||||||
isCloud: true
|
isCloud: true
|
||||||
@@ -55,7 +62,7 @@ describe('WidgetSelect asset mode', () => {
|
|||||||
nodeType: 'CheckpointLoaderSimple'
|
nodeType: 'CheckpointLoaderSimple'
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue, createTestingPinia()]
|
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ import { mount } from '@vue/test-utils'
|
|||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
import type { SelectProps } from 'primevue/select'
|
import type { SelectProps } from 'primevue/select'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import SelectPlus from '@/components/primevueOverride/SelectPlus.vue'
|
import SelectPlus from '@/components/primevueOverride/SelectPlus.vue'
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: { en: {} }
|
||||||
|
})
|
||||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||||
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
|
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
|
||||||
@@ -74,7 +81,7 @@ describe('WidgetSelect Value Binding', () => {
|
|||||||
readonly
|
readonly
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue, createTestingPinia()],
|
plugins: [PrimeVue, createTestingPinia(), i18n],
|
||||||
components: { SelectPlus }
|
components: { SelectPlus }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -226,7 +233,7 @@ describe('WidgetSelect Value Binding', () => {
|
|||||||
nodeType: 'CheckpointLoaderSimple'
|
nodeType: 'CheckpointLoaderSimple'
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue, createTestingPinia()],
|
plugins: [PrimeVue, createTestingPinia(), i18n],
|
||||||
components: { SelectPlus }
|
components: { SelectPlus }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -245,7 +252,7 @@ describe('WidgetSelect Value Binding', () => {
|
|||||||
nodeType: 'KSampler'
|
nodeType: 'KSampler'
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue, createTestingPinia()],
|
plugins: [PrimeVue, createTestingPinia(), i18n],
|
||||||
components: { SelectPlus }
|
components: { SelectPlus }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -269,7 +276,7 @@ describe('WidgetSelect Value Binding', () => {
|
|||||||
nodeType: 'CheckpointLoaderSimple'
|
nodeType: 'CheckpointLoaderSimple'
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue, createTestingPinia()],
|
plugins: [PrimeVue, createTestingPinia(), i18n],
|
||||||
components: { SelectPlus }
|
components: { SelectPlus }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -288,7 +295,7 @@ describe('WidgetSelect Value Binding', () => {
|
|||||||
nodeType: 'CheckpointLoaderSimple'
|
nodeType: 'CheckpointLoaderSimple'
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue, createTestingPinia()],
|
plugins: [PrimeVue, createTestingPinia(), i18n],
|
||||||
components: { SelectPlus }
|
components: { SelectPlus }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,16 +4,25 @@ import type { VueWrapper } from '@vue/test-utils'
|
|||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
import type { ComponentPublicInstance } from 'vue'
|
import type { ComponentPublicInstance } from 'vue'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import type { DropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
|
||||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||||
|
|
||||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: { en: {} }
|
||||||
|
})
|
||||||
|
|
||||||
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
|
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
|
||||||
inputItems: DropdownItem[]
|
inputItems: FormDropdownItem[]
|
||||||
outputItems: DropdownItem[]
|
outputItems: FormDropdownItem[]
|
||||||
|
dropdownItems: FormDropdownItem[]
|
||||||
|
filterSelected: string
|
||||||
updateSelectedItems: (selectedSet: Set<string>) => void
|
updateSelectedItems: (selectedSet: Set<string>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +59,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
|||||||
uploadFolder: 'input'
|
uploadFolder: 'input'
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue, createTestingPinia()]
|
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||||
}
|
}
|
||||||
}) as unknown as VueWrapper<WidgetSelectDropdownInstance>
|
}) as unknown as VueWrapper<WidgetSelectDropdownInstance>
|
||||||
}
|
}
|
||||||
@@ -230,9 +239,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
|||||||
).toBe(false)
|
).toBe(false)
|
||||||
|
|
||||||
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
|
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
|
||||||
const dropdownItems = (
|
const dropdownItems = wrapper.vm.dropdownItems
|
||||||
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
|
|
||||||
).dropdownItems
|
|
||||||
expect(
|
expect(
|
||||||
dropdownItems.some((item) => item.name === 'template_image.png')
|
dropdownItems.some((item) => item.name === 'template_image.png')
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
@@ -246,15 +253,10 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
|||||||
})
|
})
|
||||||
const wrapper = mountComponent(widget, 'template_image.png')
|
const wrapper = mountComponent(widget, 'template_image.png')
|
||||||
|
|
||||||
const vmWithFilter = wrapper.vm as unknown as {
|
wrapper.vm.filterSelected = 'inputs'
|
||||||
filterSelected: string
|
|
||||||
dropdownItems: DropdownItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
vmWithFilter.filterSelected = 'inputs'
|
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
const dropdownItems = vmWithFilter.dropdownItems
|
const dropdownItems = wrapper.vm.dropdownItems
|
||||||
expect(dropdownItems).toHaveLength(2)
|
expect(dropdownItems).toHaveLength(2)
|
||||||
expect(
|
expect(
|
||||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||||
@@ -267,16 +269,10 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
|||||||
})
|
})
|
||||||
const wrapper = mountComponent(widget, 'template_image.png')
|
const wrapper = mountComponent(widget, 'template_image.png')
|
||||||
|
|
||||||
const vmWithFilter = wrapper.vm as unknown as {
|
wrapper.vm.filterSelected = 'outputs'
|
||||||
filterSelected: string
|
|
||||||
dropdownItems: DropdownItem[]
|
|
||||||
outputItems: DropdownItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
vmWithFilter.filterSelected = 'outputs'
|
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
const dropdownItems = vmWithFilter.dropdownItems
|
const dropdownItems = wrapper.vm.dropdownItems
|
||||||
expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length)
|
expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length)
|
||||||
expect(
|
expect(
|
||||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||||
@@ -289,9 +285,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
|||||||
})
|
})
|
||||||
const wrapper = mountComponent(widget, 'img_001.png')
|
const wrapper = mountComponent(widget, 'img_001.png')
|
||||||
|
|
||||||
const dropdownItems = (
|
const dropdownItems = wrapper.vm.dropdownItems
|
||||||
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
|
|
||||||
).dropdownItems
|
|
||||||
expect(dropdownItems).toHaveLength(2)
|
expect(dropdownItems).toHaveLength(2)
|
||||||
expect(
|
expect(
|
||||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||||
@@ -304,9 +298,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
|||||||
})
|
})
|
||||||
const wrapper = mountComponent(widget, undefined)
|
const wrapper = mountComponent(widget, undefined)
|
||||||
|
|
||||||
const dropdownItems = (
|
const dropdownItems = wrapper.vm.dropdownItems
|
||||||
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
|
|
||||||
).dropdownItems
|
|
||||||
expect(dropdownItems).toHaveLength(2)
|
expect(dropdownItems).toHaveLength(2)
|
||||||
expect(
|
expect(
|
||||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { capitalize } from 'es-toolkit'
|
import { capitalize } from 'es-toolkit'
|
||||||
import { computed, provide, ref, toRef, watch } from 'vue'
|
import { computed, provide, ref, toRef, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||||
import { t } from '@/i18n'
|
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||||
|
import {
|
||||||
|
filterItemByBaseModels,
|
||||||
|
filterItemByOwnership
|
||||||
|
} from '@/platform/assets/utils/assetFilterUtils'
|
||||||
|
import {
|
||||||
|
getAssetBaseModels,
|
||||||
|
getAssetDisplayName,
|
||||||
|
getAssetFilename
|
||||||
|
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
|
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
|
||||||
|
import type {
|
||||||
|
FilterOption,
|
||||||
|
OwnershipOption
|
||||||
|
} from '@/platform/assets/types/filterTypes'
|
||||||
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||||
import type {
|
import type {
|
||||||
DropdownItem,
|
FormDropdownItem,
|
||||||
FilterOption,
|
LayoutMode
|
||||||
LayoutMode,
|
|
||||||
SelectedKey
|
|
||||||
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||||
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
|
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
|
||||||
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
|
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
|
||||||
@@ -49,6 +61,7 @@ const modelValue = defineModel<string | undefined>({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const queueStore = useQueueStore()
|
const queueStore = useQueueStore()
|
||||||
|
|
||||||
@@ -72,16 +85,30 @@ const filterSelected = ref('all')
|
|||||||
const filterOptions = computed<FilterOption[]>(() => {
|
const filterOptions = computed<FilterOption[]>(() => {
|
||||||
if (props.isAssetMode) {
|
if (props.isAssetMode) {
|
||||||
const categoryName = assetData?.category.value ?? 'All'
|
const categoryName = assetData?.category.value ?? 'All'
|
||||||
return [{ id: 'all', name: capitalize(categoryName) }]
|
return [{ name: capitalize(categoryName), value: 'all' }]
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ id: 'all', name: 'All' },
|
{ name: 'All', value: 'all' },
|
||||||
{ id: 'inputs', name: 'Inputs' },
|
{ name: 'Inputs', value: 'inputs' },
|
||||||
{ id: 'outputs', name: 'Outputs' }
|
{ name: 'Outputs', value: 'outputs' }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedSet = ref<Set<SelectedKey>>(new Set())
|
const ownershipSelected = ref<OwnershipOption>('all')
|
||||||
|
const showOwnershipFilter = computed(() => props.isAssetMode)
|
||||||
|
|
||||||
|
const { ownershipOptions, availableBaseModels } = useAssetFilterOptions(
|
||||||
|
() => assetData?.assets.value ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseModelSelected = ref<Set<string>>(new Set())
|
||||||
|
const showBaseModelFilter = computed(() => props.isAssetMode)
|
||||||
|
const baseModelOptions = computed<FilterOption[]>(() => {
|
||||||
|
if (!props.isAssetMode || !assetData) return []
|
||||||
|
return availableBaseModels.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedSet = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforms a value using getOptionLabel if available.
|
* Transforms a value using getOptionLabel if available.
|
||||||
@@ -100,7 +127,7 @@ function getDisplayLabel(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputItems = computed<DropdownItem[]>(() => {
|
const inputItems = computed<FormDropdownItem[]>(() => {
|
||||||
const values = props.widget.options?.values || []
|
const values = props.widget.options?.values || []
|
||||||
|
|
||||||
if (!Array.isArray(values)) {
|
if (!Array.isArray(values)) {
|
||||||
@@ -109,13 +136,12 @@ const inputItems = computed<DropdownItem[]>(() => {
|
|||||||
|
|
||||||
return values.map((value: string, index: number) => ({
|
return values.map((value: string, index: number) => ({
|
||||||
id: `input-${index}`,
|
id: `input-${index}`,
|
||||||
mediaSrc: getMediaUrl(value, 'input'),
|
preview_url: getMediaUrl(value, 'input'),
|
||||||
name: value,
|
name: value,
|
||||||
label: getDisplayLabel(value),
|
label: getDisplayLabel(value)
|
||||||
metadata: ''
|
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
const outputItems = computed<DropdownItem[]>(() => {
|
const outputItems = computed<FormDropdownItem[]>(() => {
|
||||||
if (!['image', 'video'].includes(props.assetKind ?? '')) return []
|
if (!['image', 'video'].includes(props.assetKind ?? '')) return []
|
||||||
|
|
||||||
const outputs = new Set<string>()
|
const outputs = new Set<string>()
|
||||||
@@ -140,10 +166,9 @@ const outputItems = computed<DropdownItem[]>(() => {
|
|||||||
|
|
||||||
return Array.from(outputs).map((output) => ({
|
return Array.from(outputs).map((output) => ({
|
||||||
id: `output-${output}`,
|
id: `output-${output}`,
|
||||||
mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'),
|
preview_url: getMediaUrl(output.replace(' [output]', ''), 'output'),
|
||||||
name: output,
|
name: output,
|
||||||
label: getDisplayLabel(output),
|
label: getDisplayLabel(output)
|
||||||
metadata: ''
|
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -153,23 +178,22 @@ const outputItems = computed<DropdownItem[]>(() => {
|
|||||||
* where the saved value may not exist in the current server environment.
|
* where the saved value may not exist in the current server environment.
|
||||||
* Works for both local mode (inputItems/outputItems) and cloud mode (assetData).
|
* Works for both local mode (inputItems/outputItems) and cloud mode (assetData).
|
||||||
*/
|
*/
|
||||||
const missingValueItem = computed<DropdownItem | undefined>(() => {
|
const missingValueItem = computed<FormDropdownItem | undefined>(() => {
|
||||||
const currentValue = modelValue.value
|
const currentValue = modelValue.value
|
||||||
if (!currentValue) return undefined
|
if (!currentValue) return undefined
|
||||||
|
|
||||||
// Check in cloud mode assets
|
// Check in cloud mode assets
|
||||||
if (props.isAssetMode && assetData) {
|
if (props.isAssetMode && assetData) {
|
||||||
const existsInAssets = assetData.dropdownItems.value.some(
|
const existsInAssets = assetData.assets.value.some(
|
||||||
(item) => item.name === currentValue
|
(asset) => getAssetFilename(asset) === currentValue
|
||||||
)
|
)
|
||||||
if (existsInAssets) return undefined
|
if (existsInAssets) return undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `missing-${currentValue}`,
|
id: `missing-${currentValue}`,
|
||||||
mediaSrc: '',
|
preview_url: '',
|
||||||
name: currentValue,
|
name: currentValue,
|
||||||
label: getDisplayLabel(currentValue),
|
label: getDisplayLabel(currentValue)
|
||||||
metadata: ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,20 +214,45 @@ const missingValueItem = computed<DropdownItem | undefined>(() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `missing-${currentValue}`,
|
id: `missing-${currentValue}`,
|
||||||
mediaSrc: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
|
preview_url: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
|
||||||
name: currentValue,
|
name: currentValue,
|
||||||
label: getDisplayLabel(currentValue),
|
label: getDisplayLabel(currentValue)
|
||||||
metadata: ''
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const allItems = computed<DropdownItem[]>(() => {
|
/**
|
||||||
|
* Transforms AssetItem[] to FormDropdownItem[] for cloud mode.
|
||||||
|
* Uses getAssetFilename for display name, asset.name for label.
|
||||||
|
*/
|
||||||
|
const assetItems = computed<FormDropdownItem[]>(() => {
|
||||||
|
if (!props.isAssetMode || !assetData) return []
|
||||||
|
return assetData.assets.value.map((asset) => ({
|
||||||
|
id: asset.id,
|
||||||
|
name: getAssetFilename(asset),
|
||||||
|
label: getAssetDisplayName(asset),
|
||||||
|
preview_url: asset.preview_url,
|
||||||
|
is_immutable: asset.is_immutable,
|
||||||
|
base_models: getAssetBaseModels(asset)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const ownershipFilteredAssetItems = computed<FormDropdownItem[]>(() =>
|
||||||
|
filterItemByOwnership(assetItems.value, ownershipSelected.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseModelFilteredAssetItems = computed<FormDropdownItem[]>(() =>
|
||||||
|
filterItemByBaseModels(
|
||||||
|
ownershipFilteredAssetItems.value,
|
||||||
|
baseModelSelected.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const allItems = computed<FormDropdownItem[]>(() => {
|
||||||
if (props.isAssetMode && assetData) {
|
if (props.isAssetMode && assetData) {
|
||||||
const items = assetData.dropdownItems.value
|
|
||||||
if (missingValueItem.value) {
|
if (missingValueItem.value) {
|
||||||
return [missingValueItem.value, ...items]
|
return [missingValueItem.value, ...baseModelFilteredAssetItems.value]
|
||||||
}
|
}
|
||||||
return items
|
return baseModelFilteredAssetItems.value
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
...(missingValueItem.value ? [missingValueItem.value] : []),
|
...(missingValueItem.value ? [missingValueItem.value] : []),
|
||||||
@@ -212,7 +261,7 @@ const allItems = computed<DropdownItem[]>(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const dropdownItems = computed<DropdownItem[]>(() => {
|
const dropdownItems = computed<FormDropdownItem[]>(() => {
|
||||||
if (props.isAssetMode) {
|
if (props.isAssetMode) {
|
||||||
return allItems.value
|
return allItems.value
|
||||||
}
|
}
|
||||||
@@ -290,8 +339,8 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
function updateSelectedItems(selectedItems: Set<SelectedKey>) {
|
function updateSelectedItems(selectedItems: Set<string>) {
|
||||||
let id: SelectedKey | undefined = undefined
|
let id: string | undefined = undefined
|
||||||
if (selectedItems.size > 0) {
|
if (selectedItems.size > 0) {
|
||||||
id = selectedItems.values().next().value!
|
id = selectedItems.values().next().value!
|
||||||
}
|
}
|
||||||
@@ -307,7 +356,6 @@ function updateSelectedItems(selectedItems: Set<SelectedKey>) {
|
|||||||
modelValue.value = name
|
modelValue.value = name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload file function (copied from useNodeImageUpload.ts)
|
|
||||||
const uploadFile = async (
|
const uploadFile = async (
|
||||||
file: File,
|
file: File,
|
||||||
isPasted: boolean = false,
|
isPasted: boolean = false,
|
||||||
@@ -339,7 +387,6 @@ const uploadFile = async (
|
|||||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle multiple file uploads
|
|
||||||
const uploadFiles = async (files: File[]): Promise<string[]> => {
|
const uploadFiles = async (files: File[]): Promise<string[]> => {
|
||||||
const folder = props.uploadFolder ?? 'input'
|
const folder = props.uploadFolder ?? 'input'
|
||||||
const uploadPromises = files.map((file) =>
|
const uploadPromises = files.map((file) =>
|
||||||
@@ -363,9 +410,9 @@ async function handleFilesUpdate(files: File[]) {
|
|||||||
|
|
||||||
// 2. Update widget options to include new files
|
// 2. Update widget options to include new files
|
||||||
// This simulates what addToComboValues does but for SimplifiedWidget
|
// This simulates what addToComboValues does but for SimplifiedWidget
|
||||||
if (props.widget.options?.values) {
|
const values = props.widget.options?.values
|
||||||
|
if (Array.isArray(values)) {
|
||||||
uploadedPaths.forEach((path) => {
|
uploadedPaths.forEach((path) => {
|
||||||
const values = props.widget.options!.values as string[]
|
|
||||||
if (!values.includes(path)) {
|
if (!values.includes(path)) {
|
||||||
values.push(path)
|
values.push(path)
|
||||||
}
|
}
|
||||||
@@ -400,12 +447,18 @@ function getMediaUrl(
|
|||||||
v-model:selected="selectedSet"
|
v-model:selected="selectedSet"
|
||||||
v-model:filter-selected="filterSelected"
|
v-model:filter-selected="filterSelected"
|
||||||
v-model:layout-mode="layoutMode"
|
v-model:layout-mode="layoutMode"
|
||||||
|
v-model:ownership-selected="ownershipSelected"
|
||||||
|
v-model:base-model-selected="baseModelSelected"
|
||||||
:items="dropdownItems"
|
:items="dropdownItems"
|
||||||
:placeholder="mediaPlaceholder"
|
:placeholder="mediaPlaceholder"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:uploadable="uploadable"
|
:uploadable
|
||||||
:accept="acceptTypes"
|
:accept="acceptTypes"
|
||||||
:filter-options="filterOptions"
|
:filter-options
|
||||||
|
:show-ownership-filter
|
||||||
|
:ownership-options
|
||||||
|
:show-base-model-filter
|
||||||
|
:base-model-options
|
||||||
v-bind="combinedProps"
|
v-bind="combinedProps"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@update:selected="updateSelectedItems"
|
@update:selected="updateSelectedItems"
|
||||||
|
|||||||
@@ -53,8 +53,10 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
function handleFocus(event: FocusEvent) {
|
function handleFocus(event: FocusEvent) {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target
|
||||||
target.select()
|
if (target instanceof HTMLInputElement) {
|
||||||
|
target.select()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,20 +5,19 @@ import { computed, ref, useTemplateRef } from 'vue'
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FilterOption,
|
||||||
|
OwnershipFilterOption,
|
||||||
|
OwnershipOption
|
||||||
|
} from '@/platform/assets/types/filterTypes'
|
||||||
|
|
||||||
import FormDropdownInput from './FormDropdownInput.vue'
|
import FormDropdownInput from './FormDropdownInput.vue'
|
||||||
import FormDropdownMenu from './FormDropdownMenu.vue'
|
import FormDropdownMenu from './FormDropdownMenu.vue'
|
||||||
import { defaultSearcher, getDefaultSortOptions } from './shared'
|
import { defaultSearcher, getDefaultSortOptions } from './shared'
|
||||||
import type {
|
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
|
||||||
DropdownItem,
|
|
||||||
FilterOption,
|
|
||||||
LayoutMode,
|
|
||||||
OptionId,
|
|
||||||
SelectedKey,
|
|
||||||
SortOption
|
|
||||||
} from './types'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: DropdownItem[]
|
items: FormDropdownItem[]
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
/**
|
/**
|
||||||
* If true, allows multiple selections. If a number is provided,
|
* If true, allows multiple selections. If a number is provided,
|
||||||
@@ -31,16 +30,20 @@ interface Props {
|
|||||||
accept?: string
|
accept?: string
|
||||||
filterOptions?: FilterOption[]
|
filterOptions?: FilterOption[]
|
||||||
sortOptions?: SortOption[]
|
sortOptions?: SortOption[]
|
||||||
|
showOwnershipFilter?: boolean
|
||||||
|
ownershipOptions?: OwnershipFilterOption[]
|
||||||
|
showBaseModelFilter?: boolean
|
||||||
|
baseModelOptions?: FilterOption[]
|
||||||
isSelected?: (
|
isSelected?: (
|
||||||
selected: Set<SelectedKey>,
|
selected: Set<string>,
|
||||||
item: DropdownItem,
|
item: FormDropdownItem,
|
||||||
index: number
|
index: number
|
||||||
) => boolean
|
) => boolean
|
||||||
searcher?: (
|
searcher?: (
|
||||||
query: string,
|
query: string,
|
||||||
items: DropdownItem[],
|
items: FormDropdownItem[],
|
||||||
onCleanup: (cleanupFn: () => void) => void
|
onCleanup: (cleanupFn: () => void) => void
|
||||||
) => Promise<DropdownItem[]>
|
) => Promise<FormDropdownItem[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -54,18 +57,24 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
searcher: defaultSearcher
|
searcher: defaultSearcher
|
||||||
})
|
})
|
||||||
|
|
||||||
const selected = defineModel<Set<SelectedKey>>('selected', {
|
const selected = defineModel<Set<string>>('selected', {
|
||||||
default: new Set()
|
default: () => new Set()
|
||||||
})
|
})
|
||||||
const filterSelected = defineModel<OptionId>('filterSelected', { default: '' })
|
const filterSelected = defineModel<string>('filterSelected', { default: '' })
|
||||||
const sortSelected = defineModel<OptionId>('sortSelected', {
|
const sortSelected = defineModel<string>('sortSelected', {
|
||||||
default: 'default'
|
default: 'default'
|
||||||
})
|
})
|
||||||
const layoutMode = defineModel<LayoutMode>('layoutMode', {
|
const layoutMode = defineModel<LayoutMode>('layoutMode', {
|
||||||
default: 'grid'
|
default: 'grid'
|
||||||
})
|
})
|
||||||
const files = defineModel<File[]>('files', { default: [] })
|
const files = defineModel<File[]>('files', { default: () => [] })
|
||||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||||
|
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected', {
|
||||||
|
default: 'all'
|
||||||
|
})
|
||||||
|
const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
|
||||||
|
default: () => new Set()
|
||||||
|
})
|
||||||
|
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const popoverRef = ref<InstanceType<typeof Popover>>()
|
const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||||
@@ -80,7 +89,7 @@ const maxSelectable = computed(() => {
|
|||||||
|
|
||||||
const itemsKey = computed(() => props.items.map((item) => item.id).join('|'))
|
const itemsKey = computed(() => props.items.map((item) => item.id).join('|'))
|
||||||
|
|
||||||
const filteredItems = ref<DropdownItem[]>([])
|
const filteredItems = ref<FormDropdownItem[]>([])
|
||||||
|
|
||||||
const defaultSorter = computed<SortOption['sorter']>(() => {
|
const defaultSorter = computed<SortOption['sorter']>(() => {
|
||||||
const sorter = props.sortOptions.find(
|
const sorter = props.sortOptions.find(
|
||||||
@@ -99,7 +108,7 @@ const sortedItems = computed(() => {
|
|||||||
return selectedSorter.value({ items: filteredItems.value }) || []
|
return selectedSorter.value({ items: filteredItems.value }) || []
|
||||||
})
|
})
|
||||||
|
|
||||||
function internalIsSelected(item: DropdownItem, index: number): boolean {
|
function internalIsSelected(item: FormDropdownItem, index: number): boolean {
|
||||||
return props.isSelected?.(selected.value, item, index) ?? false
|
return props.isSelected?.(selected.value, item, index) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,15 +129,15 @@ const closeDropdown = () => {
|
|||||||
|
|
||||||
function handleFileChange(event: Event) {
|
function handleFileChange(event: Event) {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
const input = event.target as HTMLInputElement
|
const target = event.target
|
||||||
if (input.files) {
|
if (!(target instanceof HTMLInputElement)) return
|
||||||
files.value = Array.from(input.files)
|
if (target.files) {
|
||||||
|
files.value = Array.from(target.files)
|
||||||
}
|
}
|
||||||
// Clear the input value to allow re-selecting the same file
|
target.value = ''
|
||||||
input.value = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelection(item: DropdownItem, index: number) {
|
function handleSelection(item: FormDropdownItem, index: number) {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
const sel = selected.value
|
const sel = selected.value
|
||||||
if (internalIsSelected(item, index)) {
|
if (internalIsSelected(item, index)) {
|
||||||
@@ -172,15 +181,15 @@ async function customSearcher(
|
|||||||
<template>
|
<template>
|
||||||
<div ref="triggerRef">
|
<div ref="triggerRef">
|
||||||
<FormDropdownInput
|
<FormDropdownInput
|
||||||
:files="files"
|
:files
|
||||||
:is-open="isOpen"
|
:is-open
|
||||||
:placeholder="placeholder"
|
:placeholder
|
||||||
:items="items"
|
:items
|
||||||
:max-selectable="maxSelectable"
|
:max-selectable
|
||||||
:selected="selected"
|
:selected
|
||||||
:uploadable="uploadable"
|
:uploadable
|
||||||
:disabled="disabled"
|
:disabled
|
||||||
:accept="accept"
|
:accept
|
||||||
@select-click="toggleDropdown"
|
@select-click="toggleDropdown"
|
||||||
@file-change="handleFileChange"
|
@file-change="handleFileChange"
|
||||||
/>
|
/>
|
||||||
@@ -204,13 +213,19 @@ async function customSearcher(
|
|||||||
v-model:layout-mode="layoutMode"
|
v-model:layout-mode="layoutMode"
|
||||||
v-model:sort-selected="sortSelected"
|
v-model:sort-selected="sortSelected"
|
||||||
v-model:search-query="searchQuery"
|
v-model:search-query="searchQuery"
|
||||||
:filter-options="filterOptions"
|
v-model:ownership-selected="ownershipSelected"
|
||||||
:sort-options="sortOptions"
|
v-model:base-model-selected="baseModelSelected"
|
||||||
:disabled="disabled"
|
:filter-options
|
||||||
|
:sort-options
|
||||||
|
:show-ownership-filter
|
||||||
|
:ownership-options
|
||||||
|
:show-base-model-filter
|
||||||
|
:base-model-options
|
||||||
|
:disabled
|
||||||
:searcher="customSearcher"
|
:searcher="customSearcher"
|
||||||
:items="sortedItems"
|
:items="sortedItems"
|
||||||
:is-selected="internalIsSelected"
|
:is-selected="internalIsSelected"
|
||||||
:max-selectable="maxSelectable"
|
:max-selectable
|
||||||
:update-key="itemsKey"
|
:update-key="itemsKey"
|
||||||
@close="closeDropdown"
|
@close="closeDropdown"
|
||||||
@item-click="handleSelection"
|
@item-click="handleSelection"
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { computed } from 'vue'
|
|||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import { WidgetInputBaseClass } from '../../layout'
|
import { WidgetInputBaseClass } from '../../layout'
|
||||||
import type { DropdownItem, SelectedKey } from './types'
|
import type { FormDropdownItem } from './types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen?: boolean
|
isOpen?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
items: DropdownItem[]
|
items: FormDropdownItem[]
|
||||||
selected: Set<SelectedKey>
|
selected: Set<string>
|
||||||
maxSelectable: number
|
maxSelectable: number
|
||||||
uploadable: boolean
|
uploadable: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
@@ -50,7 +50,6 @@ const theButtonStyle = computed(() =>
|
|||||||
})
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- Dropdown -->
|
|
||||||
<button
|
<button
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
@@ -82,7 +81,6 @@ const theButtonStyle = computed(() =>
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<!-- Open File -->
|
|
||||||
<label
|
<label
|
||||||
v-if="uploadable"
|
v-if="uploadable"
|
||||||
:class="
|
:class="
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ import type { MaybeRefOrGetter } from 'vue'
|
|||||||
|
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FilterOption,
|
||||||
|
OwnershipFilterOption,
|
||||||
|
OwnershipOption
|
||||||
|
} from '@/platform/assets/types/filterTypes'
|
||||||
|
|
||||||
import FormDropdownMenuActions from './FormDropdownMenuActions.vue'
|
import FormDropdownMenuActions from './FormDropdownMenuActions.vue'
|
||||||
import FormDropdownMenuFilter from './FormDropdownMenuFilter.vue'
|
import FormDropdownMenuFilter from './FormDropdownMenuFilter.vue'
|
||||||
import FormDropdownMenuItem from './FormDropdownMenuItem.vue'
|
import FormDropdownMenuItem from './FormDropdownMenuItem.vue'
|
||||||
import type {
|
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
|
||||||
DropdownItem,
|
|
||||||
FilterOption,
|
|
||||||
LayoutMode,
|
|
||||||
OptionId,
|
|
||||||
SortOption
|
|
||||||
} from './types'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: DropdownItem[]
|
items: FormDropdownItem[]
|
||||||
isSelected: (item: DropdownItem, index: number) => boolean
|
isSelected: (item: FormDropdownItem, index: number) => boolean
|
||||||
filterOptions: FilterOption[]
|
filterOptions: FilterOption[]
|
||||||
sortOptions: SortOption[]
|
sortOptions: SortOption[]
|
||||||
searcher?: (
|
searcher?: (
|
||||||
@@ -24,42 +24,48 @@ interface Props {
|
|||||||
onCleanup: (cleanupFn: () => void) => void
|
onCleanup: (cleanupFn: () => void) => void
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
updateKey?: MaybeRefOrGetter<unknown>
|
updateKey?: MaybeRefOrGetter<unknown>
|
||||||
|
showOwnershipFilter?: boolean
|
||||||
|
ownershipOptions?: OwnershipFilterOption[]
|
||||||
|
showBaseModelFilter?: boolean
|
||||||
|
baseModelOptions?: FilterOption[]
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'item-click', item: DropdownItem, index: number): void
|
(e: 'item-click', item: FormDropdownItem, index: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Define models for two-way binding
|
const filterSelected = defineModel<string>('filterSelected')
|
||||||
const filterSelected = defineModel<OptionId>('filterSelected')
|
|
||||||
const layoutMode = defineModel<LayoutMode>('layoutMode')
|
const layoutMode = defineModel<LayoutMode>('layoutMode')
|
||||||
const sortSelected = defineModel<OptionId>('sortSelected')
|
const sortSelected = defineModel<string>('sortSelected')
|
||||||
const searchQuery = defineModel<string>('searchQuery')
|
const searchQuery = defineModel<string>('searchQuery')
|
||||||
|
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected')
|
||||||
// Handle item selection
|
const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
|
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
|
||||||
>
|
>
|
||||||
<!-- Filter -->
|
|
||||||
<FormDropdownMenuFilter
|
<FormDropdownMenuFilter
|
||||||
v-if="filterOptions.length > 0"
|
v-if="filterOptions.length > 0"
|
||||||
v-model:filter-selected="filterSelected"
|
v-model:filter-selected="filterSelected"
|
||||||
:filter-options="filterOptions"
|
:filter-options
|
||||||
/>
|
/>
|
||||||
<!-- Actions -->
|
|
||||||
<FormDropdownMenuActions
|
<FormDropdownMenuActions
|
||||||
v-model:layout-mode="layoutMode"
|
v-model:layout-mode="layoutMode"
|
||||||
v-model:sort-selected="sortSelected"
|
v-model:sort-selected="sortSelected"
|
||||||
v-model:search-query="searchQuery"
|
v-model:search-query="searchQuery"
|
||||||
:sort-options="sortOptions"
|
v-model:ownership-selected="ownershipSelected"
|
||||||
|
v-model:base-model-selected="baseModelSelected"
|
||||||
|
:sort-options
|
||||||
:searcher
|
:searcher
|
||||||
:update-key="updateKey"
|
:update-key
|
||||||
|
:show-ownership-filter
|
||||||
|
:ownership-options
|
||||||
|
:show-base-model-filter
|
||||||
|
:base-model-options
|
||||||
/>
|
/>
|
||||||
<!-- List -->
|
|
||||||
<div class="relative flex h-full mt-2 overflow-y-scroll">
|
<div class="relative flex h-full mt-2 overflow-y-scroll">
|
||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
@@ -84,16 +90,14 @@ const searchQuery = defineModel<string>('searchQuery')
|
|||||||
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
|
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Item -->
|
|
||||||
<FormDropdownMenuItem
|
<FormDropdownMenuItem
|
||||||
v-for="(item, index) in items"
|
v-for="(item, index) in items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:index="index"
|
:index="index"
|
||||||
:selected="isSelected(item, index)"
|
:selected="isSelected(item, index)"
|
||||||
:media-src="item.mediaSrc"
|
:preview-url="item.preview_url ?? ''"
|
||||||
:name="item.name"
|
:name="item.name"
|
||||||
:label="item.label"
|
:label="item.label"
|
||||||
:metadata="item.metadata"
|
|
||||||
:layout="layoutMode"
|
:layout="layoutMode"
|
||||||
@click="emit('item-click', item, index)"
|
@click="emit('item-click', item, index)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,11 +3,20 @@ import type { MaybeRefOrGetter } from 'vue'
|
|||||||
|
|
||||||
import Popover from 'primevue/popover'
|
import Popover from 'primevue/popover'
|
||||||
import { ref, useTemplateRef } from 'vue'
|
import { ref, useTemplateRef } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import type {
|
||||||
|
FilterOption,
|
||||||
|
OwnershipFilterOption,
|
||||||
|
OwnershipOption
|
||||||
|
} from '@/platform/assets/types/filterTypes'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import FormSearchInput from '../FormSearchInput.vue'
|
import FormSearchInput from '../FormSearchInput.vue'
|
||||||
import type { LayoutMode, OptionId, SortOption } from './types'
|
import type { LayoutMode, SortOption } from './types'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
searcher?: (
|
searcher?: (
|
||||||
@@ -16,18 +25,26 @@ defineProps<{
|
|||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
sortOptions: SortOption[]
|
sortOptions: SortOption[]
|
||||||
updateKey?: MaybeRefOrGetter<unknown>
|
updateKey?: MaybeRefOrGetter<unknown>
|
||||||
|
showOwnershipFilter?: boolean
|
||||||
|
ownershipOptions?: OwnershipFilterOption[]
|
||||||
|
showBaseModelFilter?: boolean
|
||||||
|
baseModelOptions?: FilterOption[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const layoutMode = defineModel<LayoutMode>('layoutMode')
|
const layoutMode = defineModel<LayoutMode>('layoutMode')
|
||||||
const searchQuery = defineModel<string>('searchQuery')
|
const searchQuery = defineModel<string>('searchQuery')
|
||||||
const sortSelected = defineModel<OptionId>('sortSelected')
|
const sortSelected = defineModel<string>('sortSelected')
|
||||||
|
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected', {
|
||||||
|
default: 'all'
|
||||||
|
})
|
||||||
|
const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
|
||||||
|
default: new Set()
|
||||||
|
})
|
||||||
|
|
||||||
const actionButtonStyle = cn(
|
const actionButtonStyle = cn(
|
||||||
'h-8 bg-zinc-500/20 rounded-lg outline outline-1 outline-offset-[-1px] outline-node-component-border transition-all duration-150'
|
'h-8 bg-zinc-500/20 rounded-lg outline outline-1 outline-offset-[-1px] outline-node-component-border transition-all duration-150'
|
||||||
)
|
)
|
||||||
|
|
||||||
const resetInputStyle = 'bg-transparent border-0 outline-0 ring-0 text-left'
|
|
||||||
|
|
||||||
const layoutSwitchItemStyle =
|
const layoutSwitchItemStyle =
|
||||||
'size-6 flex justify-center items-center rounded-sm cursor-pointer transition-all duration-150 hover:scale-108 hover:text-base-foreground active:scale-95'
|
'size-6 flex justify-center items-center rounded-sm cursor-pointer transition-all duration-150 hover:scale-108 hover:text-base-foreground active:scale-95'
|
||||||
|
|
||||||
@@ -38,7 +55,7 @@ const isSortPopoverOpen = ref(false)
|
|||||||
function toggleSortPopover(event: Event) {
|
function toggleSortPopover(event: Event) {
|
||||||
if (!sortPopoverRef.value || !sortTriggerRef.value) return
|
if (!sortPopoverRef.value || !sortTriggerRef.value) return
|
||||||
isSortPopoverOpen.value = !isSortPopoverOpen.value
|
isSortPopoverOpen.value = !isSortPopoverOpen.value
|
||||||
sortPopoverRef.value.toggle(event, sortTriggerRef.value)
|
sortPopoverRef.value.toggle(event, sortTriggerRef.value.$el)
|
||||||
}
|
}
|
||||||
function closeSortPopover() {
|
function closeSortPopover() {
|
||||||
isSortPopoverOpen.value = false
|
isSortPopoverOpen.value = false
|
||||||
@@ -49,6 +66,42 @@ function handleSortSelected(item: SortOption) {
|
|||||||
sortSelected.value = item.id
|
sortSelected.value = item.id
|
||||||
closeSortPopover()
|
closeSortPopover()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ownershipPopoverRef = useTemplateRef('ownershipPopoverRef')
|
||||||
|
const ownershipTriggerRef = useTemplateRef('ownershipTriggerRef')
|
||||||
|
const isOwnershipPopoverOpen = ref(false)
|
||||||
|
|
||||||
|
function toggleOwnershipPopover(event: Event) {
|
||||||
|
if (!ownershipPopoverRef.value || !ownershipTriggerRef.value) return
|
||||||
|
isOwnershipPopoverOpen.value = !isOwnershipPopoverOpen.value
|
||||||
|
ownershipPopoverRef.value.toggle(event, ownershipTriggerRef.value.$el)
|
||||||
|
}
|
||||||
|
function closeOwnershipPopover() {
|
||||||
|
isOwnershipPopoverOpen.value = false
|
||||||
|
ownershipPopoverRef.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOwnershipSelected(item: OwnershipFilterOption) {
|
||||||
|
ownershipSelected.value = item.value
|
||||||
|
closeOwnershipPopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseModelPopoverRef = useTemplateRef('baseModelPopoverRef')
|
||||||
|
const baseModelTriggerRef = useTemplateRef('baseModelTriggerRef')
|
||||||
|
const isBaseModelPopoverOpen = ref(false)
|
||||||
|
|
||||||
|
function toggleBaseModelPopover(event: Event) {
|
||||||
|
if (!baseModelPopoverRef.value || !baseModelTriggerRef.value) return
|
||||||
|
isBaseModelPopoverOpen.value = !isBaseModelPopoverOpen.value
|
||||||
|
baseModelPopoverRef.value.toggle(event, baseModelTriggerRef.value.$el)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBaseModelSelection(item: FilterOption) {
|
||||||
|
const current = new Set(baseModelSelected.value)
|
||||||
|
baseModelSelected.value = current.has(item.value)
|
||||||
|
? new Set([...current].filter((v) => v !== item.value))
|
||||||
|
: new Set([...current, item.value])
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -56,7 +109,7 @@ function handleSortSelected(item: SortOption) {
|
|||||||
<FormSearchInput
|
<FormSearchInput
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
:searcher
|
:searcher
|
||||||
:update-key="updateKey"
|
:update-key
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
actionButtonStyle,
|
actionButtonStyle,
|
||||||
@@ -66,15 +119,14 @@ function handleSortSelected(item: SortOption) {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
ref="sortTriggerRef"
|
ref="sortTriggerRef"
|
||||||
|
variant="textonly"
|
||||||
|
size="icon"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
resetInputStyle,
|
|
||||||
actionButtonStyle,
|
actionButtonStyle,
|
||||||
'relative w-8 flex justify-center items-center cursor-pointer',
|
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
|
||||||
'hover:outline-component-node-widget-background-highlighted',
|
|
||||||
'active:!scale-95'
|
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@click="toggleSortPopover"
|
@click="toggleSortPopover"
|
||||||
@@ -84,8 +136,7 @@ function handleSortSelected(item: SortOption) {
|
|||||||
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
|
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
|
||||||
/>
|
/>
|
||||||
<i class="icon-[lucide--arrow-up-down] size-4" />
|
<i class="icon-[lucide--arrow-up-down] size-4" />
|
||||||
</button>
|
</Button>
|
||||||
<!-- Sort Popover -->
|
|
||||||
<Popover
|
<Popover
|
||||||
ref="sortPopoverRef"
|
ref="sortPopoverRef"
|
||||||
:dismissable="true"
|
:dismissable="true"
|
||||||
@@ -110,16 +161,12 @@ function handleSortSelected(item: SortOption) {
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
v-for="item of sortOptions"
|
v-for="item of sortOptions"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
:class="
|
variant="textonly"
|
||||||
cn(
|
size="unset"
|
||||||
resetInputStyle,
|
:class="cn('flex justify-between items-center h-6 text-left')"
|
||||||
'flex justify-between items-center h-6 cursor-pointer',
|
|
||||||
'hover:!text-blue-500'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
@click="handleSortSelected(item)"
|
@click="handleSortSelected(item)"
|
||||||
>
|
>
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
@@ -127,11 +174,143 @@ function handleSortSelected(item: SortOption) {
|
|||||||
v-if="sortSelected === item.id"
|
v-if="sortSelected === item.id"
|
||||||
class="icon-[lucide--check] size-4"
|
class="icon-[lucide--check] size-4"
|
||||||
/>
|
/>
|
||||||
</button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="showOwnershipFilter && ownershipOptions?.length"
|
||||||
|
ref="ownershipTriggerRef"
|
||||||
|
:aria-label="t('assetBrowser.ownership')"
|
||||||
|
:title="t('assetBrowser.ownership')"
|
||||||
|
variant="textonly"
|
||||||
|
size="icon"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
actionButtonStyle,
|
||||||
|
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="toggleOwnershipPopover"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="ownershipSelected !== 'all'"
|
||||||
|
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
|
||||||
|
/>
|
||||||
|
<i class="icon-[lucide--user] size-4" />
|
||||||
|
</Button>
|
||||||
|
<Popover
|
||||||
|
ref="ownershipPopoverRef"
|
||||||
|
:dismissable="true"
|
||||||
|
:close-on-escape="true"
|
||||||
|
unstyled
|
||||||
|
:pt="{
|
||||||
|
root: {
|
||||||
|
class: 'absolute z-50'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@hide="isOwnershipPopoverOpen = false"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col gap-2 p-2 min-w-32',
|
||||||
|
'bg-component-node-background',
|
||||||
|
'rounded-lg outline outline-offset-[-1px] outline-component-node-border'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-for="item of ownershipOptions"
|
||||||
|
:key="item.value"
|
||||||
|
variant="textonly"
|
||||||
|
size="unset"
|
||||||
|
:class="cn('flex justify-between items-center h-6 text-left')"
|
||||||
|
@click="handleOwnershipSelected(item)"
|
||||||
|
>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<i
|
||||||
|
v-if="ownershipSelected === item.value"
|
||||||
|
class="icon-[lucide--check] size-4"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="showBaseModelFilter && baseModelOptions?.length"
|
||||||
|
ref="baseModelTriggerRef"
|
||||||
|
:aria-label="t('assetBrowser.baseModel')"
|
||||||
|
:title="t('assetBrowser.baseModel')"
|
||||||
|
variant="textonly"
|
||||||
|
size="icon"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
actionButtonStyle,
|
||||||
|
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="toggleBaseModelPopover"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="baseModelSelected.size > 0"
|
||||||
|
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
|
||||||
|
/>
|
||||||
|
<i class="icon-[comfy--ai-model] size-4" />
|
||||||
|
</Button>
|
||||||
|
<Popover
|
||||||
|
ref="baseModelPopoverRef"
|
||||||
|
:dismissable="true"
|
||||||
|
:close-on-escape="true"
|
||||||
|
unstyled
|
||||||
|
:pt="{
|
||||||
|
root: {
|
||||||
|
class: 'absolute z-50'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@hide="isBaseModelPopoverOpen = false"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col gap-2 p-2 min-w-32',
|
||||||
|
'bg-component-node-background',
|
||||||
|
'rounded-lg outline outline-offset-[-1px] outline-component-node-border'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-for="item of baseModelOptions"
|
||||||
|
:key="item.value"
|
||||||
|
variant="textonly"
|
||||||
|
size="unset"
|
||||||
|
:class="cn('flex justify-between items-center h-6 text-left')"
|
||||||
|
@click="toggleBaseModelSelection(item)"
|
||||||
|
>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<i
|
||||||
|
v-if="baseModelSelected.has(item.value)"
|
||||||
|
class="icon-[lucide--check] size-4"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<span class="h-0 w-full border-b border-border-default" />
|
||||||
|
<Button
|
||||||
|
variant="textonly"
|
||||||
|
size="unset"
|
||||||
|
:class="cn('flex justify-between items-center h-6 text-left')"
|
||||||
|
@click="baseModelSelected = new Set()"
|
||||||
|
>
|
||||||
|
{{ t('g.clearFilters') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<!-- Layout Switch -->
|
|
||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
@@ -140,34 +319,32 @@ function handleSortSelected(item: SortOption) {
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
|
variant="textonly"
|
||||||
|
size="unset"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
resetInputStyle,
|
|
||||||
layoutSwitchItemStyle,
|
layoutSwitchItemStyle,
|
||||||
layoutMode === 'list'
|
layoutMode === 'list' && 'bg-neutral-500/50 text-base-foreground'
|
||||||
? 'bg-neutral-500/50 text-base-foreground'
|
|
||||||
: ''
|
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@click="layoutMode = 'list'"
|
@click="layoutMode = 'list'"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--list] size-4" />
|
<i class="icon-[lucide--list] size-4" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="textonly"
|
||||||
|
size="unset"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
resetInputStyle,
|
|
||||||
layoutSwitchItemStyle,
|
layoutSwitchItemStyle,
|
||||||
layoutMode === 'grid'
|
layoutMode === 'grid' && 'bg-neutral-500/50 text-base-foreground'
|
||||||
? 'bg-neutral-500/50 text-base-foreground'
|
|
||||||
: ''
|
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@click="layoutMode = 'grid'"
|
@click="layoutMode = 'grid'"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--layout-grid] size-4" />
|
<i class="icon-[lucide--layout-grid] size-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,19 +3,17 @@ import { computed } from 'vue'
|
|||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
||||||
|
import type { FilterOption } from '@/platform/assets/types/filterTypes'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import type { FilterOption, OptionId } from './types'
|
|
||||||
|
|
||||||
const { filterOptions } = defineProps<{
|
const { filterOptions } = defineProps<{
|
||||||
filterOptions: FilterOption[]
|
filterOptions: FilterOption[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const filterSelected = defineModel<OptionId>('filterSelected')
|
const filterSelected = defineModel<string>('filterSelected')
|
||||||
|
|
||||||
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload()
|
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload()
|
||||||
|
|
||||||
// TODO: Add real check to differentiate between the Model dialogs and Load Image
|
|
||||||
const singleFilterOption = computed(() => filterOptions.length === 1)
|
const singleFilterOption = computed(() => filterOptions.length === 1)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -23,7 +21,7 @@ const singleFilterOption = computed(() => filterOptions.length === 1)
|
|||||||
<div class="text-secondary mb-4 flex gap-1 px-4 justify-start">
|
<div class="text-secondary mb-4 flex gap-1 px-4 justify-start">
|
||||||
<button
|
<button
|
||||||
v-for="option in filterOptions"
|
v-for="option in filterOptions"
|
||||||
:key="option.id"
|
:key="option.value"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="singleFilterOption"
|
:disabled="singleFilterOption"
|
||||||
:class="
|
:class="
|
||||||
@@ -31,12 +29,12 @@ const singleFilterOption = computed(() => filterOptions.length === 1)
|
|||||||
'px-4 py-2 rounded-md inline-flex justify-center items-center select-none appearance-none border-0 text-base-foreground',
|
'px-4 py-2 rounded-md inline-flex justify-center items-center select-none appearance-none border-0 text-base-foreground',
|
||||||
!singleFilterOption &&
|
!singleFilterOption &&
|
||||||
'transition-all duration-150 hover:text-base-foreground hover:bg-interface-menu-component-surface-hovered cursor-pointer active:scale-95',
|
'transition-all duration-150 hover:text-base-foreground hover:bg-interface-menu-component-surface-hovered cursor-pointer active:scale-95',
|
||||||
!singleFilterOption && filterSelected === option.id
|
!singleFilterOption && filterSelected === option.value
|
||||||
? '!bg-interface-menu-component-surface-selected text-base-foreground'
|
? '!bg-interface-menu-component-surface-selected text-base-foreground'
|
||||||
: 'bg-transparent'
|
: 'bg-transparent'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@click="filterSelected = option.id"
|
@click="filterSelected = option.value"
|
||||||
>
|
>
|
||||||
{{ option.name }}
|
{{ option.name }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import type { LayoutMode } from './types'
|
|||||||
interface Props {
|
interface Props {
|
||||||
index: number
|
index: number
|
||||||
selected: boolean
|
selected: boolean
|
||||||
mediaSrc: string
|
previewUrl: string
|
||||||
name: string
|
name: string
|
||||||
label?: string
|
label?: string
|
||||||
metadata?: string
|
|
||||||
layout?: LayoutMode
|
layout?: LayoutMode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,16 +101,16 @@ function handleVideoLoad(event: Event) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<video
|
<video
|
||||||
v-if="mediaSrc && isVideo"
|
v-if="previewUrl && isVideo"
|
||||||
:src="mediaSrc"
|
:src="previewUrl"
|
||||||
class="size-full object-cover"
|
class="size-full object-cover"
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
muted
|
muted
|
||||||
@loadeddata="handleVideoLoad"
|
@loadeddata="handleVideoLoad"
|
||||||
/>
|
/>
|
||||||
<LazyImage
|
<LazyImage
|
||||||
v-else-if="mediaSrc"
|
v-else-if="previewUrl"
|
||||||
:src="mediaSrc"
|
:src="previewUrl"
|
||||||
:alt="name"
|
:alt="name"
|
||||||
image-class="size-full object-cover"
|
image-class="size-full object-cover"
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
@@ -146,9 +145,9 @@ function handleVideoLoad(event: Event) {
|
|||||||
{{ label ?? name }}
|
{{ label ?? name }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Meta Data -->
|
<!-- Meta Data -->
|
||||||
<span class="text-secondary block text-xs">{{
|
<span v-if="actualDimensions" class="text-secondary block text-xs">
|
||||||
metadata || actualDimensions
|
{{ actualDimensions }}
|
||||||
}}</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { defaultSearcher, getDefaultSortOptions } from './shared'
|
import { defaultSearcher, getDefaultSortOptions } from './shared'
|
||||||
import type { DropdownItem } from './types'
|
import type { FormDropdownItem } from './types'
|
||||||
|
|
||||||
function createItem(name: string, label?: string): DropdownItem {
|
function createItem(name: string, label?: string): FormDropdownItem {
|
||||||
return {
|
return {
|
||||||
id: name,
|
id: name,
|
||||||
mediaSrc: '',
|
preview_url: '',
|
||||||
name,
|
name,
|
||||||
label,
|
label
|
||||||
metadata: ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('defaultSearcher', () => {
|
describe('defaultSearcher', () => {
|
||||||
const items: DropdownItem[] = [
|
const items: FormDropdownItem[] = [
|
||||||
createItem('apple.png'),
|
createItem('apple.png'),
|
||||||
createItem('banana.jpg'),
|
createItem('banana.jpg'),
|
||||||
createItem('cherry.gif')
|
createItem('cherry.gif')
|
||||||
@@ -74,7 +73,7 @@ describe('getDefaultSortOptions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('A-Z sorter', () => {
|
describe('A-Z sorter', () => {
|
||||||
const azSorter = sortOptions.find((o) => o.id === 'a-z')!.sorter
|
const azSorter = sortOptions.find((o) => o.id === 'name-asc')!.sorter
|
||||||
|
|
||||||
it('sorts items alphabetically by name', () => {
|
it('sorts items alphabetically by name', () => {
|
||||||
const items = [
|
const items = [
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { DropdownItem, SortOption } from './types'
|
import { t } from '@/i18n'
|
||||||
|
import type { AssetSortOption } from '@/platform/assets/types/filterTypes'
|
||||||
|
import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
|
||||||
|
|
||||||
export async function defaultSearcher(query: string, items: DropdownItem[]) {
|
import type { FormDropdownItem, SortOption } from './types'
|
||||||
|
|
||||||
|
export async function defaultSearcher(
|
||||||
|
query: string,
|
||||||
|
items: FormDropdownItem[]
|
||||||
|
) {
|
||||||
if (query.trim() === '') return items
|
if (query.trim() === '') return items
|
||||||
const words = query.trim().toLowerCase().split(' ')
|
const words = query.trim().toLowerCase().split(' ')
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
@@ -9,25 +16,23 @@ export async function defaultSearcher(query: string, items: DropdownItem[]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultSortOptions(): SortOption[] {
|
/**
|
||||||
|
* Create a SortOption that delegates to the shared sortAssets utility
|
||||||
|
*/
|
||||||
|
function createSortOption(
|
||||||
|
id: AssetSortOption,
|
||||||
|
name: string
|
||||||
|
): SortOption<AssetSortOption> {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
sorter: ({ items }) => sortAssets(items, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultSortOptions(): SortOption<AssetSortOption>[] {
|
||||||
return [
|
return [
|
||||||
{
|
createSortOption('default', t('assetBrowser.sortDefault')),
|
||||||
name: 'Default',
|
createSortOption('name-asc', t('assetBrowser.sortAZ'))
|
||||||
id: 'default',
|
|
||||||
sorter: ({ items }) => items.slice()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'A-Z',
|
|
||||||
id: 'a-z',
|
|
||||||
sorter: ({ items }) =>
|
|
||||||
items.slice().sort((a, b) => {
|
|
||||||
const aLabel = a.label ?? a.name
|
|
||||||
const bLabel = b.label ?? b.name
|
|
||||||
return aLabel.localeCompare(bLabel, undefined, {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: 'base'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,28 @@ import type { ComputedRef, InjectionKey } from 'vue'
|
|||||||
|
|
||||||
import type { AssetKind } from '@/types/widgetTypes'
|
import type { AssetKind } from '@/types/widgetTypes'
|
||||||
|
|
||||||
export type OptionId = string | number | symbol
|
/**
|
||||||
export type SelectedKey = OptionId
|
* Minimal interface for items in FormDropdown.
|
||||||
|
* Both AssetItem (from cloud API) and local file items satisfy this contract.
|
||||||
export interface DropdownItem {
|
*/
|
||||||
id: SelectedKey
|
export interface FormDropdownItem {
|
||||||
mediaSrc: string // URL for image, video, or other media
|
id: string
|
||||||
|
/** Display name shown in the dropdown */
|
||||||
name: string
|
name: string
|
||||||
|
/** Original/alternate label (e.g., original filename) */
|
||||||
label?: string
|
label?: string
|
||||||
metadata: string
|
/** Preview image/video URL */
|
||||||
}
|
preview_url?: string
|
||||||
export interface SortOption {
|
/** Whether the item is immutable (public model) - used for ownership filtering */
|
||||||
id: OptionId
|
is_immutable?: boolean
|
||||||
name: string
|
/** Base models this item is compatible with - used for base model filtering */
|
||||||
sorter: (ctx: { items: readonly DropdownItem[] }) => DropdownItem[]
|
base_models?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterOption {
|
export interface SortOption<TId extends string = string> {
|
||||||
id: OptionId
|
id: TId
|
||||||
name: string
|
name: string
|
||||||
|
sorter: (ctx: { items: readonly FormDropdownItem[] }) => FormDropdownItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LayoutMode = 'list' | 'grid' | 'list-small'
|
export type LayoutMode = 'list' | 'grid' | 'list-small'
|
||||||
|
|||||||
@@ -29,12 +29,10 @@ vi.mock('@/stores/modelToNodeStore', () => ({
|
|||||||
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
|
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
|
||||||
it('returns empty/default values without calling stores', () => {
|
it('returns empty/default values without calling stores', () => {
|
||||||
const nodeType = ref('CheckpointLoaderSimple')
|
const nodeType = ref('CheckpointLoaderSimple')
|
||||||
const { category, assets, dropdownItems, isLoading, error } =
|
const { category, assets, isLoading, error } = useAssetWidgetData(nodeType)
|
||||||
useAssetWidgetData(nodeType)
|
|
||||||
|
|
||||||
expect(category.value).toBeUndefined()
|
expect(category.value).toBeUndefined()
|
||||||
expect(assets.value).toEqual([])
|
expect(assets.value).toEqual([])
|
||||||
expect(dropdownItems.value).toEqual([])
|
|
||||||
expect(isLoading.value).toBe(false)
|
expect(isLoading.value).toBe(false)
|
||||||
expect(error.value).toBeNull()
|
expect(error.value).toBeNull()
|
||||||
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fetches assets and transforms to dropdown items', async () => {
|
it('fetches assets for a given node type', async () => {
|
||||||
const mockAssets: AssetItem[] = [
|
const mockAssets: AssetItem[] = [
|
||||||
createMockAsset(
|
createMockAsset(
|
||||||
'asset-1',
|
'asset-1',
|
||||||
@@ -87,8 +87,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const nodeType = ref('CheckpointLoaderSimple')
|
const nodeType = ref('CheckpointLoaderSimple')
|
||||||
const { category, assets, dropdownItems, isLoading } =
|
const { category, assets, isLoading } = useAssetWidgetData(nodeType)
|
||||||
useAssetWidgetData(nodeType)
|
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await vi.waitFor(() => !isLoading.value)
|
await vi.waitFor(() => !isLoading.value)
|
||||||
@@ -98,13 +97,10 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
)
|
)
|
||||||
expect(category.value).toBe('checkpoints')
|
expect(category.value).toBe('checkpoints')
|
||||||
expect(assets.value).toEqual(mockAssets)
|
expect(assets.value).toEqual(mockAssets)
|
||||||
|
expect(assets.value).toHaveLength(2)
|
||||||
expect(dropdownItems.value).toHaveLength(2)
|
expect(assets.value[0].id).toBe('asset-1')
|
||||||
const item = dropdownItems.value[0]
|
expect(assets.value[0].name).toBe('Beautiful Model')
|
||||||
expect(item.id).toBe('asset-1')
|
expect(assets.value[0].preview_url).toBe('/api/preview/asset-1')
|
||||||
expect(item.name).toBe('models/beautiful_model.safetensors')
|
|
||||||
expect(item.label).toBe('Beautiful Model')
|
|
||||||
expect(item.mediaSrc).toBe('/api/preview/asset-1')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles API errors gracefully', async () => {
|
it('handles API errors gracefully', async () => {
|
||||||
@@ -238,7 +234,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handles undefined node type gracefully', async () => {
|
it('handles undefined node type gracefully', async () => {
|
||||||
const { category, assets, dropdownItems, isLoading, error } =
|
const { category, assets, isLoading, error } =
|
||||||
useAssetWidgetData(undefined)
|
useAssetWidgetData(undefined)
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -246,7 +242,6 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
||||||
expect(category.value).toBeUndefined()
|
expect(category.value).toBeUndefined()
|
||||||
expect(assets.value).toEqual([])
|
expect(assets.value).toEqual([])
|
||||||
expect(dropdownItems.value).toEqual([])
|
|
||||||
expect(isLoading.value).toBe(false)
|
expect(isLoading.value).toBe(false)
|
||||||
expect(error.value).toBeNull()
|
expect(error.value).toBeNull()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { computed, toValue, watch } from 'vue'
|
import { computed, toValue, watch } from 'vue'
|
||||||
import type { MaybeRefOrGetter } from 'vue'
|
import type { MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import type { DropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useAssetsStore } from '@/stores/assetsStore'
|
import { useAssetsStore } from '@/stores/assetsStore'
|
||||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||||
|
|
||||||
@@ -47,17 +46,6 @@ export function useAssetWidgetData(
|
|||||||
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
|
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
const dropdownItems = computed<DropdownItem[]>(() => {
|
|
||||||
return (assets.value ?? []).map((asset) => ({
|
|
||||||
id: asset.id,
|
|
||||||
name:
|
|
||||||
(asset.user_metadata?.filename as string | undefined) ?? asset.name,
|
|
||||||
label: asset.name,
|
|
||||||
mediaSrc: asset.preview_url ?? '',
|
|
||||||
metadata: ''
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => toValue(nodeType),
|
() => toValue(nodeType),
|
||||||
async (currentNodeType) => {
|
async (currentNodeType) => {
|
||||||
@@ -78,7 +66,6 @@ export function useAssetWidgetData(
|
|||||||
return {
|
return {
|
||||||
category,
|
category,
|
||||||
assets,
|
assets,
|
||||||
dropdownItems,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
error
|
error
|
||||||
}
|
}
|
||||||
@@ -86,8 +73,7 @@ export function useAssetWidgetData(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
category: computed(() => undefined),
|
category: computed(() => undefined),
|
||||||
assets: computed(() => []),
|
assets: computed<AssetItem[]>(() => []),
|
||||||
dropdownItems: computed(() => []),
|
|
||||||
isLoading: computed(() => false),
|
isLoading: computed(() => false),
|
||||||
error: computed(() => null)
|
error: computed(() => null)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user