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] +}