refactor: unify DropdownItem with AssetDropdownItem

- Create AssetDropdownItem in platform/assets/types

- Create toAssetDropdownItem() transform utility

- Remove deprecated DropdownItem, SelectedKey aliases

- Rename mediaSrc to previewUrl, remove unused metadata field

- Import types directly from source, remove re-exports

Amp-Thread-ID: https://ampcode.com/threads/T-019c10b4-cabd-779d-a787-1ebf5dc8a067
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-01-30 13:15:22 -08:00
parent bbceecc94a
commit 22bb79adcc
16 changed files with 127 additions and 117 deletions

View File

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,14 @@
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 DropdownItem
* Works with both AssetItem and AssetDropdownItem
*/
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 { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { DropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
inputItems: DropdownItem[]
outputItems: DropdownItem[]
inputItems: AssetDropdownItem[]
outputItems: AssetDropdownItem[]
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: DropdownItem[] }
wrapper.vm as unknown as { dropdownItems: AssetDropdownItem[] }
).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: DropdownItem[]
dropdownItems: AssetDropdownItem[]
}
vmWithFilter.filterSelected = 'inputs'
@@ -269,8 +269,8 @@ describe('WidgetSelectDropdown custom label mapping', () => {
const vmWithFilter = wrapper.vm as unknown as {
filterSelected: string
dropdownItems: DropdownItem[]
outputItems: DropdownItem[]
dropdownItems: AssetDropdownItem[]
outputItems: AssetDropdownItem[]
}
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: DropdownItem[] }
wrapper.vm as unknown as { dropdownItems: AssetDropdownItem[] }
).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: DropdownItem[] }
wrapper.vm as unknown as { dropdownItems: AssetDropdownItem[] }
).dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(

View File

@@ -6,13 +6,13 @@ import { useTransformCompatOverlayProps } from '@/composables/useTransformCompat
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { AssetDropdownItem } from '@/platform/assets/types/assetDropdownTypes'
import type {
DropdownItem,
FilterOption,
LayoutMode,
SelectedKey
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
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 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'
@@ -81,7 +81,7 @@ const filterOptions = computed<FilterOption[]>(() => {
]
})
const selectedSet = ref<Set<SelectedKey>>(new Set())
const selectedSet = ref<Set<OptionId>>(new Set())
/**
* Transforms a value using getOptionLabel if available.
@@ -100,7 +100,7 @@ function getDisplayLabel(value: string): string {
}
}
const inputItems = computed<DropdownItem[]>(() => {
const inputItems = computed<AssetDropdownItem[]>(() => {
const values = props.widget.options?.values || []
if (!Array.isArray(values)) {
@@ -109,13 +109,12 @@ const inputItems = computed<DropdownItem[]>(() => {
return values.map((value: string, index: number) => ({
id: `input-${index}`,
mediaSrc: getMediaUrl(value, 'input'),
previewUrl: getMediaUrl(value, 'input'),
name: value,
label: getDisplayLabel(value),
metadata: ''
label: getDisplayLabel(value)
}))
})
const outputItems = computed<DropdownItem[]>(() => {
const outputItems = computed<AssetDropdownItem[]>(() => {
if (!['image', 'video'].includes(props.assetKind ?? '')) return []
const outputs = new Set<string>()
@@ -140,10 +139,9 @@ const outputItems = computed<DropdownItem[]>(() => {
return Array.from(outputs).map((output) => ({
id: `output-${output}`,
mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'),
previewUrl: getMediaUrl(output.replace(' [output]', ''), 'output'),
name: output,
label: getDisplayLabel(output),
metadata: ''
label: getDisplayLabel(output)
}))
})
@@ -153,7 +151,7 @@ const outputItems = computed<DropdownItem[]>(() => {
* 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<DropdownItem | undefined>(() => {
const missingValueItem = computed<AssetDropdownItem | undefined>(() => {
const currentValue = modelValue.value
if (!currentValue) return undefined
@@ -166,10 +164,9 @@ const missingValueItem = computed<DropdownItem | undefined>(() => {
return {
id: `missing-${currentValue}`,
mediaSrc: '',
previewUrl: '',
name: currentValue,
label: getDisplayLabel(currentValue),
metadata: ''
label: getDisplayLabel(currentValue)
}
}
@@ -190,14 +187,13 @@ const missingValueItem = computed<DropdownItem | undefined>(() => {
return {
id: `missing-${currentValue}`,
mediaSrc: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
previewUrl: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
name: currentValue,
label: getDisplayLabel(currentValue),
metadata: ''
label: getDisplayLabel(currentValue)
}
})
const allItems = computed<DropdownItem[]>(() => {
const allItems = computed<AssetDropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
const items = assetData.dropdownItems.value
if (missingValueItem.value) {
@@ -212,7 +208,7 @@ const allItems = computed<DropdownItem[]>(() => {
]
})
const dropdownItems = computed<DropdownItem[]>(() => {
const dropdownItems = computed<AssetDropdownItem[]>(() => {
if (props.isAssetMode) {
return allItems.value
}
@@ -290,8 +286,8 @@ watch(
{ immediate: true }
)
function updateSelectedItems(selectedItems: Set<SelectedKey>) {
let id: SelectedKey | undefined = undefined
function updateSelectedItems(selectedItems: Set<OptionId>) {
let id: OptionId | undefined = undefined
if (selectedItems.size > 0) {
id = selectedItems.values().next().value!
}

View File

@@ -5,20 +5,19 @@ 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
} from '@/platform/assets/types/filterTypes'
import FormDropdownInput from './FormDropdownInput.vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import type {
DropdownItem,
FilterOption,
LayoutMode,
OptionId,
SelectedKey,
SortOption
} from './types'
import type { LayoutMode, SortOption } from './types'
interface Props {
items: DropdownItem[]
items: AssetDropdownItem[]
placeholder?: string
/**
* If true, allows multiple selections. If a number is provided,
@@ -32,15 +31,15 @@ interface Props {
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
isSelected?: (
selected: Set<SelectedKey>,
item: DropdownItem,
selected: Set<OptionId>,
item: AssetDropdownItem,
index: number
) => boolean
searcher?: (
query: string,
items: DropdownItem[],
items: AssetDropdownItem[],
onCleanup: (cleanupFn: () => void) => void
) => Promise<DropdownItem[]>
) => Promise<AssetDropdownItem[]>
}
const props = withDefaults(defineProps<Props>(), {
@@ -54,7 +53,7 @@ const props = withDefaults(defineProps<Props>(), {
searcher: defaultSearcher
})
const selected = defineModel<Set<SelectedKey>>('selected', {
const selected = defineModel<Set<OptionId>>('selected', {
default: new Set()
})
const filterSelected = defineModel<OptionId>('filterSelected', { default: '' })
@@ -80,7 +79,7 @@ const maxSelectable = computed(() => {
const itemsKey = computed(() => props.items.map((item) => item.id).join('|'))
const filteredItems = ref<DropdownItem[]>([])
const filteredItems = ref<AssetDropdownItem[]>([])
const defaultSorter = computed<SortOption['sorter']>(() => {
const sorter = props.sortOptions.find(
@@ -99,7 +98,7 @@ const sortedItems = computed(() => {
return selectedSorter.value({ items: filteredItems.value }) || []
})
function internalIsSelected(item: DropdownItem, index: number): boolean {
function internalIsSelected(item: AssetDropdownItem, index: number): boolean {
return props.isSelected?.(selected.value, item, index) ?? false
}
@@ -128,7 +127,7 @@ function handleFileChange(event: Event) {
input.value = ''
}
function handleSelection(item: DropdownItem, index: number) {
function handleSelection(item: AssetDropdownItem, index: number) {
if (props.disabled) return
const sel = selected.value
if (internalIsSelected(item, index)) {

View File

@@ -1,16 +1,17 @@
<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 { DropdownItem, SelectedKey } from './types'
interface Props {
isOpen?: boolean
placeholder?: string
items: DropdownItem[]
selected: Set<SelectedKey>
items: AssetDropdownItem[]
selected: Set<OptionId>
maxSelectable: number
uploadable: boolean
disabled: boolean

View File

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

View File

@@ -4,10 +4,11 @@ import type { MaybeRefOrGetter } from 'vue'
import Popover from 'primevue/popover'
import { ref, useTemplateRef } from 'vue'
import type { OptionId } from '@/platform/assets/types/filterTypes'
import { cn } from '@/utils/tailwindUtil'
import FormSearchInput from '../FormSearchInput.vue'
import type { LayoutMode, OptionId, SortOption } from './types'
import type { LayoutMode, SortOption } from './types'
defineProps<{
searcher?: (

View File

@@ -3,10 +3,12 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type {
FilterOption,
OptionId
} from '@/platform/assets/types/filterTypes'
import { cn } from '@/utils/tailwindUtil'
import type { FilterOption, OptionId } from './types'
const { filterOptions } = defineProps<{
filterOptions: FilterOption[]
}>()

View File

@@ -10,10 +10,9 @@ import type { LayoutMode } from './types'
interface Props {
index: number
selected: boolean
mediaSrc: string
previewUrl: string
name: string
label?: string
metadata?: string
layout?: LayoutMode
}
@@ -102,16 +101,16 @@ function handleVideoLoad(event: Event) {
/>
</div>
<video
v-if="mediaSrc && isVideo"
:src="mediaSrc"
v-if="previewUrl && isVideo"
:src="previewUrl"
class="size-full object-cover"
preload="metadata"
muted
@loadeddata="handleVideoLoad"
/>
<LazyImage
v-else-if="mediaSrc"
:src="mediaSrc"
v-else-if="previewUrl"
:src="previewUrl"
:alt="name"
image-class="size-full object-cover"
@load="handleImageLoad"
@@ -146,9 +145,9 @@ function handleVideoLoad(event: Event) {
{{ label ?? name }}
</span>
<!-- Meta Data -->
<span class="text-secondary block text-xs">{{
metadata || actualDimensions
}}</span>
<span v-if="actualDimensions" class="text-secondary block text-xs">
{{ actualDimensions }}
</span>
</div>
</div>
</template>

View File

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

View File

@@ -1,9 +1,13 @@
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 { DropdownItem, SortOption } from './types'
import type { SortOption } from './types'
export async function defaultSearcher(query: string, items: DropdownItem[]) {
export async function defaultSearcher(
query: string,
items: AssetDropdownItem[]
) {
if (query.trim() === '') return items
const words = query.trim().toLowerCase().split(' ')
return items.filter((item) => {

View File

@@ -1,27 +1,13 @@
import type { ComputedRef, InjectionKey } from 'vue'
import type {
FilterOption,
OptionId
} from '@/platform/assets/types/filterTypes'
import type { AssetDropdownItem } from '@/platform/assets/types/assetDropdownTypes'
import type { OptionId } from '@/platform/assets/types/filterTypes'
import type { AssetKind } from '@/types/widgetTypes'
export type { FilterOption, OptionId }
export type SelectedKey = OptionId
export interface DropdownItem {
id: SelectedKey
mediaSrc: string // URL for image, video, or other media
name: string
label?: string
metadata: string
}
export interface SortOption<TId extends OptionId = OptionId> {
id: TId
name: string
sorter: (ctx: { items: readonly DropdownItem[] }) => DropdownItem[]
sorter: (ctx: { items: readonly AssetDropdownItem[] }) => AssetDropdownItem[]
}
export type LayoutMode = 'list' | 'grid' | 'list-small'

View File

@@ -104,7 +104,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
expect(item.id).toBe('asset-1')
expect(item.name).toBe('models/beautiful_model.safetensors')
expect(item.label).toBe('Beautiful Model')
expect(item.mediaSrc).toBe('/api/preview/asset-1')
expect(item.previewUrl).toBe('/api/preview/asset-1')
})
it('handles API errors gracefully', async () => {

View File

@@ -1,9 +1,10 @@
import { computed, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { DropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
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'
@@ -47,15 +48,8 @@ export function useAssetWidgetData(
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: ''
}))
const dropdownItems = computed<AssetDropdownItem[]>(() => {
return (assets.value ?? []).map(toAssetDropdownItem)
})
watch(