refactor: replace AssetDropdownItem with FormDropdownItem

- Create minimal FormDropdownItem interface in dropdown/types.ts

- Remove dropdownItems computed from useAssetWidgetData

- Transform assets in WidgetSelectDropdown using getAssetFilename()

- Delete assetDropdownTypes.ts and assetDropdownUtils.ts

- Update all FormDropdown components to use FormDropdownItem

Amp-Thread-ID: https://ampcode.com/threads/T-019c10c6-5e4c-774a-90ce-cb00178ad6e3
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-01-30 13:31:50 -08:00
parent 22bb79adcc
commit c10a10c02d
14 changed files with 91 additions and 107 deletions

View File

@@ -1,15 +0,0 @@
import type { OptionId } from './filterTypes'
/**
* Lightweight display projection of AssetItem for dropdown/selection UIs.
* Used by FormDropdown and WidgetSelectDropdown.
*/
export interface AssetDropdownItem {
id: OptionId
/** Display name (user-defined filename or asset name) */
name: string
/** Original filename from asset */
label?: string
/** Preview image/video URL */
previewUrl: string
}

View File

@@ -1,14 +0,0 @@
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetDropdownItem } from '../types/assetDropdownTypes'
/**
* Transforms an AssetItem to AssetDropdownItem for dropdown display.
*/
export function toAssetDropdownItem(asset: AssetItem): AssetDropdownItem {
return {
id: asset.id,
name: (asset.user_metadata?.filename as string | undefined) ?? asset.name,
label: asset.name,
previewUrl: asset.preview_url ?? ''
}
}

View File

@@ -7,7 +7,7 @@ import type { AssetSortOption } from '../types/filterTypes'
/**
* Minimal interface for sortable items
* Works with both AssetItem and AssetDropdownItem
* Works with both AssetItem and FormDropdownItem
*/
export interface SortableItem {
name: string

View File

@@ -5,15 +5,15 @@ import PrimeVue from 'primevue/config'
import type { ComponentPublicInstance } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { AssetDropdownItem } from '@/platform/assets/types/assetDropdownTypes'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
inputItems: AssetDropdownItem[]
outputItems: AssetDropdownItem[]
inputItems: FormDropdownItem[]
outputItems: FormDropdownItem[]
updateSelectedItems: (selectedSet: Set<string>) => void
}
@@ -231,7 +231,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: AssetDropdownItem[] }
wrapper.vm as unknown as { dropdownItems: FormDropdownItem[] }
).dropdownItems
expect(
dropdownItems.some((item) => item.name === 'template_image.png')
@@ -248,7 +248,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
const vmWithFilter = wrapper.vm as unknown as {
filterSelected: string
dropdownItems: AssetDropdownItem[]
dropdownItems: FormDropdownItem[]
}
vmWithFilter.filterSelected = 'inputs'
@@ -269,8 +269,8 @@ describe('WidgetSelectDropdown custom label mapping', () => {
const vmWithFilter = wrapper.vm as unknown as {
filterSelected: string
dropdownItems: AssetDropdownItem[]
outputItems: AssetDropdownItem[]
dropdownItems: FormDropdownItem[]
outputItems: FormDropdownItem[]
}
vmWithFilter.filterSelected = 'outputs'
@@ -290,7 +290,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
const wrapper = mountComponent(widget, 'img_001.png')
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: AssetDropdownItem[] }
wrapper.vm as unknown as { dropdownItems: FormDropdownItem[] }
).dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
@@ -305,7 +305,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
const wrapper = mountComponent(widget, undefined)
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: AssetDropdownItem[] }
wrapper.vm as unknown as { dropdownItems: FormDropdownItem[] }
).dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(

View File

@@ -4,15 +4,21 @@ import { computed, provide, ref, toRef, watch } from 'vue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { t } from '@/i18n'
import {
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { useToastStore } from '@/platform/updates/common/toastStore'
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
import type { AssetDropdownItem } from '@/platform/assets/types/assetDropdownTypes'
import type {
FilterOption,
OptionId
} from '@/platform/assets/types/filterTypes'
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types';
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types';
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type {
FormDropdownItem,
LayoutMode
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
import type { ResultItemType } from '@/schemas/apiSchema'
@@ -100,7 +106,7 @@ function getDisplayLabel(value: string): string {
}
}
const inputItems = computed<AssetDropdownItem[]>(() => {
const inputItems = computed<FormDropdownItem[]>(() => {
const values = props.widget.options?.values || []
if (!Array.isArray(values)) {
@@ -109,12 +115,12 @@ const inputItems = computed<AssetDropdownItem[]>(() => {
return values.map((value: string, index: number) => ({
id: `input-${index}`,
previewUrl: getMediaUrl(value, 'input'),
preview_url: getMediaUrl(value, 'input'),
name: value,
label: getDisplayLabel(value)
}))
})
const outputItems = computed<AssetDropdownItem[]>(() => {
const outputItems = computed<FormDropdownItem[]>(() => {
if (!['image', 'video'].includes(props.assetKind ?? '')) return []
const outputs = new Set<string>()
@@ -139,7 +145,7 @@ const outputItems = computed<AssetDropdownItem[]>(() => {
return Array.from(outputs).map((output) => ({
id: `output-${output}`,
previewUrl: getMediaUrl(output.replace(' [output]', ''), 'output'),
preview_url: getMediaUrl(output.replace(' [output]', ''), 'output'),
name: output,
label: getDisplayLabel(output)
}))
@@ -151,20 +157,20 @@ const outputItems = computed<AssetDropdownItem[]>(() => {
* where the saved value may not exist in the current server environment.
* Works for both local mode (inputItems/outputItems) and cloud mode (assetData).
*/
const missingValueItem = computed<AssetDropdownItem | undefined>(() => {
const missingValueItem = computed<FormDropdownItem | undefined>(() => {
const currentValue = modelValue.value
if (!currentValue) return undefined
// Check in cloud mode assets
if (props.isAssetMode && assetData) {
const existsInAssets = assetData.dropdownItems.value.some(
(item) => item.name === currentValue
const existsInAssets = assetData.assets.value.some(
(asset) => asset.name === currentValue
)
if (existsInAssets) return undefined
return {
id: `missing-${currentValue}`,
previewUrl: '',
preview_url: '',
name: currentValue,
label: getDisplayLabel(currentValue)
}
@@ -187,19 +193,32 @@ const missingValueItem = computed<AssetDropdownItem | undefined>(() => {
return {
id: `missing-${currentValue}`,
previewUrl: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
preview_url: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
name: currentValue,
label: getDisplayLabel(currentValue)
}
})
const allItems = computed<AssetDropdownItem[]>(() => {
/**
* 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
}))
})
const allItems = computed<FormDropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
const items = assetData.dropdownItems.value
if (missingValueItem.value) {
return [missingValueItem.value, ...items]
return [missingValueItem.value, ...assetItems.value]
}
return items
return assetItems.value
}
return [
...(missingValueItem.value ? [missingValueItem.value] : []),
@@ -208,7 +227,7 @@ const allItems = computed<AssetDropdownItem[]>(() => {
]
})
const dropdownItems = computed<AssetDropdownItem[]>(() => {
const dropdownItems = computed<FormDropdownItem[]>(() => {
if (props.isAssetMode) {
return allItems.value
}

View File

@@ -5,7 +5,6 @@ import { computed, ref, useTemplateRef } from 'vue'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { AssetDropdownItem } from '@/platform/assets/types/assetDropdownTypes'
import type {
FilterOption,
OptionId
@@ -14,10 +13,10 @@ import type {
import FormDropdownInput from './FormDropdownInput.vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import type { LayoutMode, SortOption } from './types'
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
items: AssetDropdownItem[]
items: FormDropdownItem[]
placeholder?: string
/**
* If true, allows multiple selections. If a number is provided,
@@ -32,14 +31,14 @@ interface Props {
sortOptions?: SortOption[]
isSelected?: (
selected: Set<OptionId>,
item: AssetDropdownItem,
item: FormDropdownItem,
index: number
) => boolean
searcher?: (
query: string,
items: AssetDropdownItem[],
items: FormDropdownItem[],
onCleanup: (cleanupFn: () => void) => void
) => Promise<AssetDropdownItem[]>
) => Promise<FormDropdownItem[]>
}
const props = withDefaults(defineProps<Props>(), {
@@ -79,7 +78,7 @@ const maxSelectable = computed(() => {
const itemsKey = computed(() => props.items.map((item) => item.id).join('|'))
const filteredItems = ref<AssetDropdownItem[]>([])
const filteredItems = ref<FormDropdownItem[]>([])
const defaultSorter = computed<SortOption['sorter']>(() => {
const sorter = props.sortOptions.find(
@@ -98,7 +97,7 @@ const sortedItems = computed(() => {
return selectedSorter.value({ items: filteredItems.value }) || []
})
function internalIsSelected(item: AssetDropdownItem, index: number): boolean {
function internalIsSelected(item: FormDropdownItem, index: number): boolean {
return props.isSelected?.(selected.value, item, index) ?? false
}
@@ -127,7 +126,7 @@ function handleFileChange(event: Event) {
input.value = ''
}
function handleSelection(item: AssetDropdownItem, index: number) {
function handleSelection(item: FormDropdownItem, index: number) {
if (props.disabled) return
const sel = selected.value
if (internalIsSelected(item, index)) {

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { AssetDropdownItem } from '@/platform/assets/types/assetDropdownTypes'
import type { OptionId } from '@/platform/assets/types/filterTypes'
import { cn } from '@/utils/tailwindUtil'
import { WidgetInputBaseClass } from '../../layout'
import type { FormDropdownItem } from './types'
interface Props {
isOpen?: boolean
placeholder?: string
items: AssetDropdownItem[]
items: FormDropdownItem[]
selected: Set<OptionId>
maxSelectable: number
uploadable: boolean

View File

@@ -3,7 +3,6 @@ import type { MaybeRefOrGetter } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import type { AssetDropdownItem } from '@/platform/assets/types/assetDropdownTypes'
import type {
FilterOption,
OptionId
@@ -12,11 +11,11 @@ import type {
import FormDropdownMenuActions from './FormDropdownMenuActions.vue'
import FormDropdownMenuFilter from './FormDropdownMenuFilter.vue'
import FormDropdownMenuItem from './FormDropdownMenuItem.vue'
import type { LayoutMode, SortOption } from './types'
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
items: AssetDropdownItem[]
isSelected: (item: AssetDropdownItem, index: number) => boolean
items: FormDropdownItem[]
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
searcher?: (
@@ -28,7 +27,7 @@ interface Props {
defineProps<Props>()
const emit = defineEmits<{
(e: 'item-click', item: AssetDropdownItem, index: number): void
(e: 'item-click', item: FormDropdownItem, index: number): void
}>()
// Define models for two-way binding
@@ -90,7 +89,7 @@ const searchQuery = defineModel<string>('searchQuery')
:key="item.id"
:index="index"
:selected="isSelected(item, index)"
:preview-url="item.previewUrl"
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:layout="layoutMode"

View File

@@ -1,20 +1,19 @@
import { describe, expect, it } from 'vitest'
import type { AssetDropdownItem } from '@/platform/assets/types/assetDropdownTypes'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import type { FormDropdownItem } from './types'
function createItem(name: string, label?: string): AssetDropdownItem {
function createItem(name: string, label?: string): FormDropdownItem {
return {
id: name,
previewUrl: '',
preview_url: '',
name,
label
}
}
describe('defaultSearcher', () => {
const items: AssetDropdownItem[] = [
const items: FormDropdownItem[] = [
createItem('apple.png'),
createItem('banana.jpg'),
createItem('cherry.gif')

View File

@@ -1,12 +1,11 @@
import type { AssetDropdownItem } from '@/platform/assets/types/assetDropdownTypes'
import type { AssetSortOption } from '@/platform/assets/types/filterTypes'
import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
import type { SortOption } from './types'
import type { FormDropdownItem, SortOption } from './types'
export async function defaultSearcher(
query: string,
items: AssetDropdownItem[]
items: FormDropdownItem[]
) {
if (query.trim() === '') return items
const words = query.trim().toLowerCase().split(' ')

View File

@@ -1,13 +1,26 @@
import type { ComputedRef, InjectionKey } from 'vue'
import type { AssetDropdownItem } from '@/platform/assets/types/assetDropdownTypes'
import type { OptionId } from '@/platform/assets/types/filterTypes'
import type { AssetKind } from '@/types/widgetTypes'
/**
* Minimal interface for items in FormDropdown.
* Both AssetItem (from cloud API) and local file items satisfy this contract.
*/
export interface FormDropdownItem {
id: OptionId
/** Display name shown in the dropdown */
name: string
/** Original/alternate label (e.g., original filename) */
label?: string
/** Preview image/video URL */
preview_url?: string
}
export interface SortOption<TId extends OptionId = OptionId> {
id: TId
name: string
sorter: (ctx: { items: readonly AssetDropdownItem[] }) => AssetDropdownItem[]
sorter: (ctx: { items: readonly FormDropdownItem[] }) => FormDropdownItem[]
}
export type LayoutMode = 'list' | 'grid' | 'list-small'

View File

@@ -29,12 +29,10 @@ vi.mock('@/stores/modelToNodeStore', () => ({
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
it('returns empty/default values without calling stores', () => {
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, dropdownItems, isLoading, error } =
useAssetWidgetData(nodeType)
const { category, assets, isLoading, error } = useAssetWidgetData(nodeType)
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(dropdownItems.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()

View File

@@ -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[] = [
createMockAsset(
'asset-1',
@@ -87,8 +87,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
)
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, dropdownItems, isLoading } =
useAssetWidgetData(nodeType)
const { category, assets, isLoading } = useAssetWidgetData(nodeType)
await nextTick()
await vi.waitFor(() => !isLoading.value)
@@ -98,13 +97,10 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
expect(dropdownItems.value).toHaveLength(2)
const item = dropdownItems.value[0]
expect(item.id).toBe('asset-1')
expect(item.name).toBe('models/beautiful_model.safetensors')
expect(item.label).toBe('Beautiful Model')
expect(item.previewUrl).toBe('/api/preview/asset-1')
expect(assets.value).toHaveLength(2)
expect(assets.value[0].id).toBe('asset-1')
expect(assets.value[0].name).toBe('Beautiful Model')
expect(assets.value[0].preview_url).toBe('/api/preview/asset-1')
})
it('handles API errors gracefully', async () => {
@@ -238,7 +234,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
})
it('handles undefined node type gracefully', async () => {
const { category, assets, dropdownItems, isLoading, error } =
const { category, assets, isLoading, error } =
useAssetWidgetData(undefined)
await nextTick()
@@ -246,7 +242,6 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(dropdownItems.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})

View File

@@ -2,8 +2,6 @@ import { computed, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { AssetDropdownItem } from '@/platform/assets/types/assetDropdownTypes'
import { toAssetDropdownItem } from '@/platform/assets/utils/assetDropdownUtils'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
@@ -48,10 +46,6 @@ export function useAssetWidgetData(
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
})
const dropdownItems = computed<AssetDropdownItem[]>(() => {
return (assets.value ?? []).map(toAssetDropdownItem)
})
watch(
() => toValue(nodeType),
async (currentNodeType) => {
@@ -72,7 +66,6 @@ export function useAssetWidgetData(
return {
category,
assets,
dropdownItems,
isLoading,
error
}
@@ -80,8 +73,7 @@ export function useAssetWidgetData(
return {
category: computed(() => undefined),
assets: computed(() => []),
dropdownItems: computed(() => []),
assets: computed<AssetItem[]>(() => []),
isLoading: computed(() => false),
error: computed(() => null)
}