From c92123aeceff7bcdfcb7ec92feb3fc7c5d7c6675 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 2 Nov 2025 09:54:47 -0800 Subject: [PATCH] [bugfix] Fix Vue nodes combo widgets not displaying deserialized values Fixes an issue where combo widgets in Vue nodes would not display values from deserialized workflows if those values were not in the current options list (e.g., deleted model files, removed checkpoints). This brings Vue nodes behavior in line with legacy canvas rendering, which always displays the current value regardless of whether it exists in the options. --- .../widgets/components/WidgetSelect.test.ts | 35 ++++++ .../components/WidgetSelectDefault.vue | 3 +- .../components/WidgetSelectDropdown.vue | 8 +- .../widgets/utils/widgetOptionsUtils.test.ts | 117 ++++++++++++++++++ .../widgets/utils/widgetOptionsUtils.ts | 31 +++++ 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 src/renderer/extensions/vueNodes/widgets/utils/widgetOptionsUtils.test.ts create mode 100644 src/renderer/extensions/vueNodes/widgets/utils/widgetOptionsUtils.ts diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts index 5aae57b01..8bc674e4c 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts @@ -179,6 +179,41 @@ describe('WidgetSelect Value Binding', () => { expect(emitted).toBeDefined() expect(emitted![0]).toContain('100') }) + + it('displays value not in options list (deserialized workflow value)', async () => { + // Simulate a workflow loaded with a value that's no longer in the options + // (e.g., a deleted model file) + const currentOptions = ['model1.ckpt', 'model2.safetensors'] + const deserializedValue = 'old_deleted_model.ckpt' + const widget = createMockWidget(deserializedValue, { + values: currentOptions + }) + const wrapper = mountComponent(widget, deserializedValue) + + const select = wrapper.findComponent({ name: 'Select' }) + const options = select.props('options') + + // The current value should be included in the options + expect(options).toContain(deserializedValue) + // And it should be first + expect(options[0]).toBe(deserializedValue) + // Original options should still be present + expect(options).toContain('model1.ckpt') + expect(options).toContain('model2.safetensors') + }) + + it('does not duplicate value if it exists in options', async () => { + const options = ['option1', 'option2', 'option3'] + const widget = createMockWidget('option2', { values: options }) + const wrapper = mountComponent(widget, 'option2') + + const select = wrapper.findComponent({ name: 'Select' }) + const selectOptions = select.props('options') + + // Should not duplicate the value + expect(selectOptions).toEqual(options) + expect(selectOptions.filter((opt) => opt === 'option2')).toHaveLength(1) + }) }) describe('Spec-aware rendering', () => { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue index cb69ac7b8..d543a81a1 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue @@ -32,6 +32,7 @@ import { import { WidgetInputBaseClass } from './layout' import WidgetLayoutField from './layout/WidgetLayoutField.vue' +import { ensureValueInOptions } from '../utils/widgetOptionsUtils' const props = defineProps<{ widget: SimplifiedWidget @@ -63,7 +64,7 @@ const selectOptions = computed(() => { const options = props.widget.options if (options?.values && Array.isArray(options.values)) { - return options.values + return ensureValueInOptions(options.values, localValue.value) } return [] diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index bb5accb81..6f609dd7a 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -24,6 +24,7 @@ import type { SelectedKey } from './form/dropdown/types' import WidgetLayoutField from './layout/WidgetLayoutField.vue' +import { ensureValueInOptions } from '../utils/widgetOptionsUtils' const props = defineProps<{ widget: SimplifiedWidget @@ -74,7 +75,12 @@ const inputItems = computed(() => { return [] } - return values.map((value: string, index: number) => ({ + const valuesWithCurrent = ensureValueInOptions( + values, + typeof localValue.value === 'string' ? localValue.value : undefined + ) + + return valuesWithCurrent.map((value: string, index: number) => ({ id: `input-${index}`, mediaSrc: getMediaUrl(value, 'input'), name: value, diff --git a/src/renderer/extensions/vueNodes/widgets/utils/widgetOptionsUtils.test.ts b/src/renderer/extensions/vueNodes/widgets/utils/widgetOptionsUtils.test.ts new file mode 100644 index 000000000..21fb2aa01 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/utils/widgetOptionsUtils.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' + +import { ensureValueInOptions } from './widgetOptionsUtils' + +describe('ensureValueInOptions', () => { + describe('when value exists in options', () => { + it('returns original options without duplicates', () => { + const options = ['option1', 'option2', 'option3'] + const result = ensureValueInOptions(options, 'option2') + + expect(result).toEqual(options) + expect(result).toHaveLength(3) + }) + + it('handles first option', () => { + const options = ['first', 'second', 'third'] + const result = ensureValueInOptions(options, 'first') + + expect(result).toEqual(options) + }) + + it('handles last option', () => { + const options = ['first', 'second', 'third'] + const result = ensureValueInOptions(options, 'third') + + expect(result).toEqual(options) + }) + }) + + describe('when value is missing from options', () => { + it('prepends missing value to options array', () => { + const options = ['option1', 'option2', 'option3'] + const result = ensureValueInOptions(options, 'deleted_model.safetensors') + + expect(result).toEqual([ + 'deleted_model.safetensors', + 'option1', + 'option2', + 'option3' + ]) + expect(result).toHaveLength(4) + }) + + it('preserves deserialized workflow values', () => { + const options = ['current_model.ckpt'] + const oldValue = 'old_model_from_workflow.ckpt' + const result = ensureValueInOptions(options, oldValue) + + expect(result[0]).toBe(oldValue) + expect(result).toContain('current_model.ckpt') + }) + + it('handles numeric values', () => { + const options = [1, 2, 3] + const result = ensureValueInOptions(options, 99) + + expect(result).toEqual([99, 1, 2, 3]) + }) + }) + + describe('when value is null or empty', () => { + it('returns original options for undefined', () => { + const options = ['option1', 'option2'] + const result = ensureValueInOptions(options, undefined) + + expect(result).toEqual(options) + }) + + it('returns original options for null', () => { + const options = ['option1', 'option2'] + const result = ensureValueInOptions(options, null) + + expect(result).toEqual(options) + }) + + it('returns original options for empty string', () => { + const options = ['option1', 'option2'] + const result = ensureValueInOptions(options, '') + + expect(result).toEqual(options) + }) + }) + + describe('edge cases', () => { + it('handles empty options array', () => { + const result = ensureValueInOptions([], 'some_value') + + expect(result).toEqual(['some_value']) + }) + + it('handles options with special characters', () => { + const options = ['normal.txt', 'with spaces.png', 'special@#$.jpg'] + const result = ensureValueInOptions( + options, + 'another file with spaces.png' + ) + + expect(result[0]).toBe('another file with spaces.png') + expect(result).toHaveLength(4) + }) + + it('creates new array instance (does not mutate input)', () => { + const options = ['option1', 'option2'] + const result = ensureValueInOptions(options, 'option1') + + expect(result).not.toBe(options) + expect(result).toEqual(options) + }) + + it('handles readonly arrays', () => { + const options = ['a', 'b', 'c'] as const + const result = ensureValueInOptions(options, 'd') + + expect(result).toEqual(['d', 'a', 'b', 'c']) + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/utils/widgetOptionsUtils.ts b/src/renderer/extensions/vueNodes/widgets/utils/widgetOptionsUtils.ts new file mode 100644 index 000000000..e9e1f2ea8 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/utils/widgetOptionsUtils.ts @@ -0,0 +1,31 @@ +/** + * Utility functions for widget option handling + */ + +/** + * Ensures the current value is included in the options array. + * This preserves legacy behavior where deserialized workflow values + * can be shown even if they're not in the current options list + * (e.g., deleted models, removed files, etc.) + * + * @param options - The available options from widget.options.values + * @param currentValue - The current widget value + * @returns Options array with current value prepended if missing + */ +export function ensureValueInOptions( + options: readonly T[], + currentValue: T | undefined | null +): T[] { + // Early return for empty/null values + if (currentValue == null || currentValue === '') { + return [...options] + } + + // If value already exists, return original options + if (options.includes(currentValue)) { + return [...options] + } + + // Prepend missing value to options + return [currentValue, ...options] +}