mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-11 08:00:21 +00:00
fix: show load widget inputs in media dropdown (#9670)
Main targeted, built on https://github.com/Comfy-Org/ComfyUI_frontend/pull/9551 ## Summary Fix Load Image/Load Video input dropdown tabs not showing available input assets in Vue node select dropdown. ## Changes - **What**: Keep combo widget `options` object identity while exposing dynamic `values` for cloud/remote combos. - **What**: Remove temporary debug logging and restore clearer dropdown filter branching. - **What**: Remove stale `searcher`/`updateKey` prop plumbing in dropdown menu/actions and update related tests. ## Review Focus Verify `Load Image` / `Load Video` Inputs tab behavior and confirm cloud/remote combo option values still update correctly. Relates to #9551 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9670-fix-show-load-widget-inputs-in-media-dropdown-31e6d73d36508148b845e18268a60c2a) by [Unito](https://www.unito.io) --------- Co-authored-by: bymyself <cbyrne@comfy.org> Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
import FormDropdown from './FormDropdown.vue'
|
||||
import type { FormDropdownItem } from './types'
|
||||
|
||||
function createItem(id: string, name: string): FormDropdownItem {
|
||||
return { id, preview_url: '', name, label: name }
|
||||
}
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
addAlert: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const MockFormDropdownMenu = defineComponent({
|
||||
name: 'FormDropdownMenu',
|
||||
props: {
|
||||
items: { type: Array as () => FormDropdownItem[], default: () => [] },
|
||||
isSelected: { type: Function, default: undefined },
|
||||
filterOptions: { type: Array, default: () => [] },
|
||||
sortOptions: { type: Array, default: () => [] },
|
||||
maxSelectable: { type: Number, default: 1 },
|
||||
disabled: { type: Boolean, default: false },
|
||||
showOwnershipFilter: { type: Boolean, default: false },
|
||||
ownershipOptions: { type: Array, default: () => [] },
|
||||
showBaseModelFilter: { type: Boolean, default: false },
|
||||
baseModelOptions: { type: Array, default: () => [] }
|
||||
},
|
||||
setup() {
|
||||
return () => h('div', { class: 'mock-menu' })
|
||||
}
|
||||
})
|
||||
|
||||
function mountDropdown(items: FormDropdownItem[]) {
|
||||
return mount(FormDropdown, {
|
||||
props: { items },
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
stubs: {
|
||||
FormDropdownInput: true,
|
||||
Popover: { template: '<div><slot /></div>' },
|
||||
FormDropdownMenu: MockFormDropdownMenu
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getMenuItems(
|
||||
wrapper: ReturnType<typeof mountDropdown>
|
||||
): FormDropdownItem[] {
|
||||
return wrapper
|
||||
.findComponent(MockFormDropdownMenu)
|
||||
.props('items') as FormDropdownItem[]
|
||||
}
|
||||
|
||||
describe('FormDropdown', () => {
|
||||
describe('filteredItems updates when items prop changes', () => {
|
||||
it('updates displayed items when items prop changes', async () => {
|
||||
const wrapper = mountDropdown([
|
||||
createItem('input-0', 'video1.mp4'),
|
||||
createItem('input-1', 'video2.mp4')
|
||||
])
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems(wrapper)).toHaveLength(2)
|
||||
|
||||
await wrapper.setProps({
|
||||
items: [
|
||||
createItem('output-0', 'rendered1.mp4'),
|
||||
createItem('output-1', 'rendered2.mp4')
|
||||
]
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const menuItems = getMenuItems(wrapper)
|
||||
expect(menuItems).toHaveLength(2)
|
||||
expect(menuItems[0].name).toBe('rendered1.mp4')
|
||||
})
|
||||
|
||||
it('updates when items change but IDs stay the same', async () => {
|
||||
const wrapper = mountDropdown([createItem('1', 'alpha')])
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.setProps({ items: [createItem('1', 'beta')] })
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems(wrapper)[0].name).toBe('beta')
|
||||
})
|
||||
|
||||
it('updates when switching between empty and non-empty items', async () => {
|
||||
const wrapper = mountDropdown([])
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems(wrapper)).toHaveLength(0)
|
||||
|
||||
await wrapper.setProps({ items: [createItem('1', 'video.mp4')] })
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems(wrapper)).toHaveLength(1)
|
||||
expect(getMenuItems(wrapper)[0].name).toBe('video.mp4')
|
||||
|
||||
await wrapper.setProps({ items: [] })
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems(wrapper)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computedAsync, refDebounced } from '@vueuse/core'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -101,9 +102,16 @@ const maxSelectable = computed(() => {
|
||||
return 1
|
||||
})
|
||||
|
||||
const itemsKey = computed(() => items.map((item) => item.id).join('|'))
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 250, { maxWait: 1000 })
|
||||
|
||||
const filteredItems = ref<FormDropdownItem[]>([])
|
||||
const filteredItems = computedAsync(async (onCancel) => {
|
||||
let cleanupFn: (() => void) | undefined
|
||||
onCancel(() => cleanupFn?.())
|
||||
const result = await searcher(debouncedSearchQuery.value, items, (cb) => {
|
||||
cleanupFn = cb
|
||||
})
|
||||
return result
|
||||
}, [])
|
||||
|
||||
const defaultSorter = computed<SortOption['sorter']>(() => {
|
||||
const sorter = sortOptions.find((option) => option.id === 'default')?.sorter
|
||||
@@ -171,21 +179,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
async function customSearcher(
|
||||
query: string,
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) {
|
||||
let isCleanup = false
|
||||
let cleanupFn: undefined | (() => void)
|
||||
onCleanup(() => {
|
||||
isCleanup = true
|
||||
cleanupFn?.()
|
||||
})
|
||||
await searcher(query, items, (cb) => (cleanupFn = cb)).then((results) => {
|
||||
if (!isCleanup) filteredItems.value = results
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -233,11 +226,9 @@ async function customSearcher(
|
||||
:show-base-model-filter
|
||||
:base-model-options
|
||||
:disabled
|
||||
:searcher="customSearcher"
|
||||
:items="sortedItems"
|
||||
:is-selected="internalIsSelected"
|
||||
:max-selectable
|
||||
:update-key="itemsKey"
|
||||
@close="closeDropdown"
|
||||
@item-click="handleSelection"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
@@ -20,11 +20,6 @@ interface Props {
|
||||
isSelected: (item: FormDropdownItem, index: number) => boolean
|
||||
filterOptions: FilterOption[]
|
||||
sortOptions: SortOption[]
|
||||
searcher?: (
|
||||
query: string,
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<void>
|
||||
updateKey?: MaybeRefOrGetter<unknown>
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -36,8 +31,6 @@ const {
|
||||
isSelected,
|
||||
filterOptions,
|
||||
sortOptions,
|
||||
searcher,
|
||||
updateKey,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
@@ -118,8 +111,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
v-model:ownership-selected="ownershipSelected"
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:sort-options
|
||||
:searcher
|
||||
:update-key
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import Popover from 'primevue/popover'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -19,12 +17,7 @@ import type { LayoutMode, SortOption } from './types'
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
searcher?: (
|
||||
query: string,
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<void>
|
||||
sortOptions: SortOption[]
|
||||
updateKey?: MaybeRefOrGetter<unknown>
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -108,8 +101,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
<div class="text-secondary flex gap-2 px-4">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key
|
||||
:class="
|
||||
cn(
|
||||
actionButtonStyle,
|
||||
|
||||
@@ -109,8 +109,15 @@ function createMockNode(comfyClass = 'TestNode'): LGraphNode {
|
||||
|
||||
// Spy on the addWidget method
|
||||
vi.spyOn(node, 'addWidget').mockImplementation(
|
||||
(type, name, value, callback) => {
|
||||
const widget = createMockWidget({ type, name, value })
|
||||
(type, name, value, callback, options = {}) => {
|
||||
const normalizedOptions =
|
||||
typeof options === 'string' ? { property: options } : options
|
||||
const widget = createMockWidget({
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
options: normalizedOptions
|
||||
})
|
||||
// Store the callback function on the widget for testing
|
||||
if (typeof callback === 'function') {
|
||||
widget.callback = callback
|
||||
@@ -320,7 +327,7 @@ describe('useComboWidget', () => {
|
||||
HASH_FILENAME,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
values: [], // Empty initially, populated dynamically by Proxy
|
||||
values: [], // Empty initially, populated via dynamic getter
|
||||
getOptionLabel: expect.any(Function)
|
||||
})
|
||||
)
|
||||
@@ -328,6 +335,23 @@ describe('useComboWidget', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('should keep the original options object for cloud input mappings', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME]
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
const addWidgetCall = vi.mocked(mockNode.addWidget).mock.calls[0]
|
||||
const options = addWidgetCall[4]
|
||||
|
||||
expect(widget.options).toBe(options)
|
||||
})
|
||||
|
||||
it("should format option labels using store's getInputName function", () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockGetInputName.mockReturnValue('Beautiful Sunset.png')
|
||||
|
||||
@@ -44,6 +44,29 @@ const NODE_PLACEHOLDER_MAP: Record<string, string> = {
|
||||
LoadAudio: 'widgets.uploadSelect.placeholderAudio'
|
||||
}
|
||||
|
||||
const bindDynamicValuesOption = (
|
||||
widget: IBaseWidget,
|
||||
getValues: () => unknown
|
||||
) => {
|
||||
const options = widget.options
|
||||
let fallbackValues = Array.isArray(options.values)
|
||||
? options.values
|
||||
: ([] as unknown[])
|
||||
|
||||
Object.defineProperty(options, 'values', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
const values = getValues()
|
||||
if (values === undefined || values === null) return fallbackValues
|
||||
return values
|
||||
},
|
||||
set: (values: unknown[]) => {
|
||||
fallbackValues = Array.isArray(values) ? values : fallbackValues
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addMultiSelectWidget = (
|
||||
node: LGraphNode,
|
||||
inputSpec: ComboInputSpec
|
||||
@@ -133,22 +156,16 @@ const createInputMappingWidget = (
|
||||
})
|
||||
}
|
||||
|
||||
const origOptions = widget.options
|
||||
widget.options = new Proxy(origOptions, {
|
||||
get(target, prop) {
|
||||
if (prop !== 'values') {
|
||||
return target[prop as keyof typeof target]
|
||||
}
|
||||
return assetsStore.inputAssets
|
||||
.filter(
|
||||
(asset) =>
|
||||
getMediaTypeFromFilename(asset.name) ===
|
||||
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
|
||||
)
|
||||
.map((asset) => asset.asset_hash)
|
||||
.filter((hash): hash is string => !!hash)
|
||||
}
|
||||
})
|
||||
bindDynamicValuesOption(widget, () =>
|
||||
assetsStore.inputAssets
|
||||
.filter(
|
||||
(asset) =>
|
||||
getMediaTypeFromFilename(asset.name) ===
|
||||
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
|
||||
)
|
||||
.map((asset) => asset.asset_hash)
|
||||
.filter((hash): hash is string => !!hash)
|
||||
)
|
||||
|
||||
if (inputSpec.control_after_generate) {
|
||||
if (!isComboWidget(widget)) {
|
||||
@@ -210,15 +227,7 @@ const addComboWidget = (
|
||||
})
|
||||
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
|
||||
|
||||
const origOptions = widget.options
|
||||
widget.options = new Proxy(origOptions, {
|
||||
get(target, prop) {
|
||||
// Assertion: Proxy handler passthrough
|
||||
return prop !== 'values'
|
||||
? target[prop as keyof typeof target]
|
||||
: remoteWidget.getValue()
|
||||
}
|
||||
})
|
||||
bindDynamicValuesOption(widget, () => remoteWidget.getValue())
|
||||
}
|
||||
|
||||
if (inputSpec.control_after_generate) {
|
||||
|
||||
Reference in New Issue
Block a user