diff --git a/src/components/graph/widgets/DropdownComboWidget.vue b/src/components/graph/widgets/DropdownComboWidget.vue new file mode 100644 index 000000000..b73728f90 --- /dev/null +++ b/src/components/graph/widgets/DropdownComboWidget.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/composables/widgets/useComboWidget.ts b/src/composables/widgets/useComboWidget.ts index 25becfd97..0f6466f82 100644 --- a/src/composables/widgets/useComboWidget.ts +++ b/src/composables/widgets/useComboWidget.ts @@ -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([]) @@ -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 = () => { diff --git a/src/composables/widgets/useDropdownComboWidget.ts b/src/composables/widgets/useDropdownComboWidget.ts new file mode 100644 index 000000000..2d299f013 --- /dev/null +++ b/src/composables/widgets/useDropdownComboWidget.ts @@ -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(defaultValue) + + // Create the widget instance + const widget = new ComponentWidgetImpl({ + 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 +} diff --git a/tests-ui/tests/composables/useDropdownComboWidget.test.ts b/tests-ui/tests/composables/useDropdownComboWidget.test.ts new file mode 100644 index 000000000..16d92ddcc --- /dev/null +++ b/tests-ui/tests/composables/useDropdownComboWidget.test.ts @@ -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') + }) +}) diff --git a/tests-ui/tests/composables/widgets/useComboWidget.test.ts b/tests-ui/tests/composables/widgets/useComboWidget.test.ts index cbdd74bab..d08a7a792 100644 --- a/tests-ui/tests/composables/widgets/useComboWidget.test.ts +++ b/tests-ui/tests/composables/widgets/useComboWidget.test.ts @@ -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: {} }) }) })