Add Vue Combo (dropdown) widget (#4113)

This commit is contained in:
Christian Byrne
2025-06-09 00:51:08 -07:00
committed by GitHub
parent 593ac576da
commit 928dfc6b8e
5 changed files with 321 additions and 74 deletions

View File

@@ -0,0 +1,40 @@
<template>
<div class="px-2">
<Select
v-model="selectedValue"
:options="computedOptions"
:placeholder="placeholder"
class="w-full rounded-lg"
:disabled="isLoading"
/>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
const selectedValue = defineModel<string>()
const { widget } = defineProps<{
widget?: ComponentWidget<string>
}>()
const inputSpec = (widget?.inputSpec ?? {}) as ComboInputSpec
const placeholder = 'Select option'
const isLoading = computed(() => selectedValue.value === 'Loading...')
// For remote widgets, we need to dynamically get options
const computedOptions = computed(() => {
if (inputSpec.remote) {
// For remote widgets, the options may be dynamically updated
// The useRemoteWidget will update the inputSpec.options
return inputSpec.options ?? []
}
return inputSpec.options ?? []
})
// Tooltip support is available via inputSpec.tooltip if needed in the future
</script>

View File

@@ -1,5 +1,4 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
@@ -15,14 +14,9 @@ import {
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
import { useRemoteWidget } from './useRemoteWidget'
import { useDropdownComboWidget } from './useDropdownComboWidget'
const getDefaultValue = (inputSpec: ComboInputSpec) => {
if (inputSpec.default) return inputSpec.default
if (inputSpec.options?.length) return inputSpec.options[0]
if (inputSpec.remote) return 'Loading...'
return undefined
}
// Default value logic is now handled in useDropdownComboWidget
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const widgetValue = ref<string[]>([])
@@ -45,53 +39,9 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
}
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? []
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
() => {},
{
values: comboOptions
}
) as IComboWidget
if (inputSpec.remote) {
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
node,
widget
})
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()
}
})
}
if (inputSpec.control_after_generate) {
// TODO: Re-implement control widget functionality without circular dependency
console.warn(
'Control widget functionality temporarily disabled for combo widgets due to circular dependency'
)
// widget.linkedWidgets = addValueControlWidgets(
// node,
// widget,
// undefined,
// undefined,
// transformInputSpecV2ToV1(inputSpec)
// )
}
return widget
// Use the new dropdown combo widget for single-selection combo widgets
const dropdownWidget = useDropdownComboWidget()
return dropdownWidget(node, inputSpec)
}
export const useComboWidget = () => {

View File

@@ -0,0 +1,98 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { ref } from 'vue'
import DropdownComboWidget from '@/components/graph/widgets/DropdownComboWidget.vue'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import type {
ComboInputSpec,
InputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
import { addValueControlWidgets } from '@/scripts/widgets'
import { useRemoteWidget } from './useRemoteWidget'
const PADDING = 8
const getDefaultValue = (inputSpec: ComboInputSpec) => {
if (inputSpec.default) return inputSpec.default
if (inputSpec.options?.length) return inputSpec.options[0]
if (inputSpec.remote) return 'Loading...'
return ''
}
export const useDropdownComboWidget = (
options: { defaultValue?: string } = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
// Type assertion to ComboInputSpec since this is specifically for combo widgets
const comboInputSpec = inputSpec as ComboInputSpec
// Initialize widget value
const defaultValue = options.defaultValue ?? getDefaultValue(comboInputSpec)
const widgetValue = ref<string>(defaultValue)
// Create the widget instance
const widget = new ComponentWidgetImpl<string>({
node,
name: inputSpec.name,
component: DropdownComboWidget,
inputSpec,
options: {
// Required: getter for widget value
getValue: () => widgetValue.value,
// Required: setter for widget value
setValue: (value: string) => {
widgetValue.value = value
},
// Optional: minimum height for the widget (dropdown needs some height)
getMinHeight: () => 42 + PADDING,
// Optional: whether to serialize this widget's value
serialize: true
}
})
// Handle remote widget functionality
if (comboInputSpec.remote) {
const remoteWidget = useRemoteWidget({
remoteConfig: comboInputSpec.remote,
defaultValue,
node,
widget: widget as any // Cast to be compatible with the remote widget interface
})
if (comboInputSpec.remote.refresh_button) {
remoteWidget.addRefreshButton()
}
// Update the widget to use remote data
// Note: The remote widget will handle updating the options through the inputSpec
}
// Handle control_after_generate widgets
if (comboInputSpec.control_after_generate) {
const linkedWidgets = addValueControlWidgets(
node,
widget as any, // Cast to be compatible with legacy widget interface
undefined,
undefined,
transformInputSpecV2ToV1(comboInputSpec)
)
// Store reference to linked widgets (mimicking original behavior)
;(widget as any).linkedWidgets = linkedWidgets
}
// Register the widget with the node
addWidget(node, widget as any)
return widget
}
return widgetConstructor
}

View File

@@ -0,0 +1,109 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDropdownComboWidget } from '@/composables/widgets/useDropdownComboWidget'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
// Mock the domWidget store and related dependencies
vi.mock('@/scripts/domWidget', () => ({
ComponentWidgetImpl: vi.fn().mockImplementation((config) => ({
name: config.name,
value: '',
options: config.options
})),
addWidget: vi.fn()
}))
// Mock the scripts/widgets for control widgets
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn().mockReturnValue([])
}))
// Mock the remote widget functionality
vi.mock('@/composables/widgets/useRemoteWidget', () => ({
useRemoteWidget: vi.fn(() => ({
addRefreshButton: vi.fn()
}))
}))
const createMockNode = () => {
return {
widgets: [],
graph: {
setDirtyCanvas: vi.fn()
},
addWidget: vi.fn(),
addCustomWidget: vi.fn(),
setDirtyCanvas: vi.fn()
} as unknown as LGraphNode
}
describe('useDropdownComboWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates widget constructor successfully', () => {
const constructor = useDropdownComboWidget()
expect(constructor).toBeDefined()
expect(typeof constructor).toBe('function')
})
it('widget constructor handles input spec correctly', () => {
const constructor = useDropdownComboWidget()
const mockNode = createMockNode()
const inputSpec: ComboInputSpec = {
name: 'test_dropdown',
type: 'COMBO',
options: ['option1', 'option2', 'option3']
}
const widget = constructor(mockNode, inputSpec)
expect(widget).toBeDefined()
expect(widget.name).toBe('test_dropdown')
})
it('widget constructor accepts default value option', () => {
const constructor = useDropdownComboWidget({ defaultValue: 'custom' })
expect(constructor).toBeDefined()
expect(typeof constructor).toBe('function')
})
it('handles remote widgets correctly', () => {
const constructor = useDropdownComboWidget()
const mockNode = createMockNode()
const inputSpec: ComboInputSpec = {
name: 'remote_dropdown',
type: 'COMBO',
options: [],
remote: {
route: '/api/options',
refresh_button: true
}
}
const widget = constructor(mockNode, inputSpec)
expect(widget).toBeDefined()
expect(widget.name).toBe('remote_dropdown')
})
it('handles control_after_generate widgets correctly', () => {
const constructor = useDropdownComboWidget()
const mockNode = createMockNode()
const inputSpec: ComboInputSpec = {
name: 'control_dropdown',
type: 'COMBO',
options: ['option1', 'option2'],
control_after_generate: true
}
const widget = constructor(mockNode, inputSpec)
expect(widget).toBeDefined()
expect(widget.name).toBe('control_dropdown')
})
})

View File

@@ -1,39 +1,89 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useComboWidget } from '@/composables/widgets/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
// Mock the dropdown combo widget since COMBO now uses it
vi.mock('@/composables/widgets/useDropdownComboWidget', () => ({
useDropdownComboWidget: vi.fn(() =>
vi.fn().mockReturnValue({ name: 'mockWidget' })
)
}))
// Mock the domWidget store and related dependencies
vi.mock('@/scripts/domWidget', () => ({
ComponentWidgetImpl: vi.fn().mockImplementation((config) => ({
name: config.name,
value: [],
options: config.options
})),
addWidget: vi.fn()
}))
const createMockNode = () => {
return {
widgets: [],
graph: {
setDirtyCanvas: vi.fn()
},
addWidget: vi.fn(),
addCustomWidget: vi.fn(),
setDirtyCanvas: vi.fn()
} as unknown as LGraphNode
}
describe('useComboWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should handle undefined spec', () => {
it('should delegate single-selection combo to dropdown widget', () => {
const constructor = useComboWidget()
const mockNode = {
addWidget: vi.fn().mockReturnValue({ options: {} } as any)
}
const mockNode = createMockNode()
const inputSpec: InputSpec = {
const inputSpec: ComboInputSpec = {
type: 'COMBO',
name: 'inputName'
name: 'inputName',
options: ['option1', 'option2']
}
const widget = constructor(mockNode as any, inputSpec)
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'inputName',
undefined, // default value
expect.any(Function), // callback
expect.objectContaining({
values: []
})
// Should create a widget (delegated to dropdown widget)
expect(widget).toBeDefined()
expect(widget.name).toBe('mockWidget')
})
it('should use multi-select widget for multi_select combo', () => {
const constructor = useComboWidget()
const mockNode = createMockNode()
const inputSpec: ComboInputSpec = {
type: 'COMBO',
name: 'multiSelectInput',
options: ['option1', 'option2'],
multi_select: { placeholder: 'Select multiple' }
}
const widget = constructor(mockNode, inputSpec)
// Should create a multi-select widget
expect(widget).toBeDefined()
expect(widget.name).toBe('multiSelectInput')
})
it('should handle invalid input spec', () => {
const constructor = useComboWidget()
const mockNode = createMockNode()
const invalidSpec = {
type: 'NOT_COMBO',
name: 'invalidInput'
} as any
expect(() => constructor(mockNode, invalidSpec)).toThrow(
'Invalid input data'
)
expect(widget).toEqual({ options: {} })
})
})