mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 15:40:10 +00:00
Add Vue Combo (dropdown) widget (#4113)
This commit is contained in:
40
src/components/graph/widgets/DropdownComboWidget.vue
Normal file
40
src/components/graph/widgets/DropdownComboWidget.vue
Normal 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>
|
||||
@@ -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 = () => {
|
||||
|
||||
98
src/composables/widgets/useDropdownComboWidget.ts
Normal file
98
src/composables/widgets/useDropdownComboWidget.ts
Normal 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
|
||||
}
|
||||
109
tests-ui/tests/composables/useDropdownComboWidget.test.ts
Normal file
109
tests-ui/tests/composables/useDropdownComboWidget.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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: {} })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user