Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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"
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand All @@ -36,8 +31,6 @@ const {
isSelected,
filterOptions,
sortOptions,
searcher,
updateKey,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -320,14 +327,31 @@ 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)
})
)
expect(widget).toBe(mockWidget)
}
)

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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading