diff --git a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png index 3d0ae2ed0..745415a11 100644 Binary files a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png and b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png differ diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts index 730ceb0bf..9c1271e7d 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts @@ -152,6 +152,50 @@ describe('WidgetSelectDropdown custom label mapping', () => { expect(consoleErrorSpy).toHaveBeenCalled() consoleErrorSpy.mockRestore() }) + + it('falls back to original value when label mapping returns empty string', () => { + const getOptionLabel = vi.fn((value: string | null) => { + if (value === 'photo_abc.jpg') { + return '' + } + return `Labeled: ${value}` + }) + + const widget = createMockWidget('img_001.png', { + getOptionLabel + }) + const wrapper = mountComponent(widget, 'img_001.png') + + const inputItems = wrapper.vm.inputItems + expect(inputItems[0].name).toBe('img_001.png') + expect(inputItems[0].label).toBe('Labeled: img_001.png') + expect(inputItems[1].name).toBe('photo_abc.jpg') + expect(inputItems[1].label).toBe('photo_abc.jpg') + expect(inputItems[2].name).toBe('hash789.png') + expect(inputItems[2].label).toBe('Labeled: hash789.png') + }) + + it('falls back to original value when label mapping returns undefined', () => { + const getOptionLabel = vi.fn((value: string | null) => { + if (value === 'hash789.png') { + return undefined as unknown as string + } + return `Labeled: ${value}` + }) + + const widget = createMockWidget('img_001.png', { + getOptionLabel + }) + const wrapper = mountComponent(widget, 'img_001.png') + + const inputItems = wrapper.vm.inputItems + expect(inputItems[0].name).toBe('img_001.png') + expect(inputItems[0].label).toBe('Labeled: img_001.png') + expect(inputItems[1].name).toBe('photo_abc.jpg') + expect(inputItems[1].label).toBe('Labeled: photo_abc.jpg') + expect(inputItems[2].name).toBe('hash789.png') + expect(inputItems[2].label).toBe('hash789.png') + }) }) describe('output items with custom label mapping', () => { @@ -171,4 +215,102 @@ describe('WidgetSelectDropdown custom label mapping', () => { expect(Array.isArray(outputItems)).toBe(true) }) }) + + describe('missing value handling for template-loaded nodes', () => { + it('creates a fallback item in "all" filter when modelValue is not in available items', () => { + const widget = createMockWidget('template_image.png', { + values: ['img_001.png', 'photo_abc.jpg'] + }) + const wrapper = mountComponent(widget, 'template_image.png') + + const inputItems = wrapper.vm.inputItems + expect(inputItems).toHaveLength(2) + expect( + inputItems.some((item) => item.name === 'template_image.png') + ).toBe(false) + + // The missing value should be accessible via dropdownItems when filter is 'all' (default) + const dropdownItems = ( + wrapper.vm as unknown as { dropdownItems: DropdownItem[] } + ).dropdownItems + expect( + dropdownItems.some((item) => item.name === 'template_image.png') + ).toBe(true) + expect(dropdownItems[0].name).toBe('template_image.png') + expect(dropdownItems[0].id).toBe('missing-template_image.png') + }) + + it('does not include fallback item when filter is "inputs"', async () => { + const widget = createMockWidget('template_image.png', { + values: ['img_001.png', 'photo_abc.jpg'] + }) + const wrapper = mountComponent(widget, 'template_image.png') + + const vmWithFilter = wrapper.vm as unknown as { + filterSelected: string + dropdownItems: DropdownItem[] + } + + vmWithFilter.filterSelected = 'inputs' + await wrapper.vm.$nextTick() + + const dropdownItems = vmWithFilter.dropdownItems + expect(dropdownItems).toHaveLength(2) + expect( + dropdownItems.every((item) => !String(item.id).startsWith('missing-')) + ).toBe(true) + }) + + it('does not include fallback item when filter is "outputs"', async () => { + const widget = createMockWidget('template_image.png', { + values: ['img_001.png', 'photo_abc.jpg'] + }) + const wrapper = mountComponent(widget, 'template_image.png') + + const vmWithFilter = wrapper.vm as unknown as { + filterSelected: string + dropdownItems: DropdownItem[] + outputItems: DropdownItem[] + } + + vmWithFilter.filterSelected = 'outputs' + await wrapper.vm.$nextTick() + + const dropdownItems = vmWithFilter.dropdownItems + expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length) + expect( + dropdownItems.every((item) => !String(item.id).startsWith('missing-')) + ).toBe(true) + }) + + it('does not create a fallback item when modelValue exists in available items', () => { + const widget = createMockWidget('img_001.png', { + values: ['img_001.png', 'photo_abc.jpg'] + }) + const wrapper = mountComponent(widget, 'img_001.png') + + const dropdownItems = ( + wrapper.vm as unknown as { dropdownItems: DropdownItem[] } + ).dropdownItems + expect(dropdownItems).toHaveLength(2) + expect( + dropdownItems.every((item) => !String(item.id).startsWith('missing-')) + ).toBe(true) + }) + + it('does not create a fallback item when modelValue is undefined', () => { + const widget = createMockWidget(undefined as unknown as string, { + values: ['img_001.png', 'photo_abc.jpg'] + }) + const wrapper = mountComponent(widget, undefined) + + const dropdownItems = ( + wrapper.vm as unknown as { dropdownItems: DropdownItem[] } + ).dropdownItems + expect(dropdownItems).toHaveLength(2) + expect( + dropdownItems.every((item) => !String(item.id).startsWith('missing-')) + ).toBe(true) + }) + }) }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index 84cb49218..2b0304ed7 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -85,14 +85,15 @@ const selectedSet = ref>(new Set()) /** * Transforms a value using getOptionLabel if available. - * Falls back to the original value if getOptionLabel is not provided or throws an error. + * Falls back to the original value if getOptionLabel is not provided, + * returns undefined/null, or throws an error. */ function getDisplayLabel(value: string): string { const getOptionLabel = props.widget.options?.getOptionLabel if (!getOptionLabel) return value try { - return getOptionLabel(value) + return getOptionLabel(value) || value } catch (e) { console.error('Failed to map value:', e) return value @@ -146,11 +147,69 @@ const outputItems = computed(() => { })) }) +/** + * Creates a fallback item for the current modelValue when it doesn't exist + * in the available items list. This handles cases like template-loaded nodes + * where the saved value may not exist in the current server environment. + * Works for both local mode (inputItems/outputItems) and cloud mode (assetData). + */ +const missingValueItem = computed(() => { + const currentValue = modelValue.value + if (!currentValue) return undefined + + // Check in cloud mode assets + if (props.isAssetMode && assetData) { + const existsInAssets = assetData.dropdownItems.value.some( + (item) => item.name === currentValue + ) + if (existsInAssets) return undefined + + return { + id: `missing-${currentValue}`, + mediaSrc: '', + name: currentValue, + label: getDisplayLabel(currentValue), + metadata: '' + } + } + + // Check in local mode inputs/outputs + const existsInInputs = inputItems.value.some( + (item) => item.name === currentValue + ) + const existsInOutputs = outputItems.value.some( + (item) => item.name === currentValue + ) + + if (existsInInputs || existsInOutputs) return undefined + + const isOutput = currentValue.endsWith(' [output]') + const strippedValue = isOutput + ? currentValue.replace(' [output]', '') + : currentValue + + return { + id: `missing-${currentValue}`, + mediaSrc: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'), + name: currentValue, + label: getDisplayLabel(currentValue), + metadata: '' + } +}) + const allItems = computed(() => { if (props.isAssetMode && assetData) { - return assetData.dropdownItems.value + const items = assetData.dropdownItems.value + if (missingValueItem.value) { + return [missingValueItem.value, ...items] + } + return items } - return [...inputItems.value, ...outputItems.value] + return [ + ...(missingValueItem.value ? [missingValueItem.value] : []), + ...inputItems.value, + ...outputItems.value + ] }) const dropdownItems = computed(() => { @@ -165,7 +224,7 @@ const dropdownItems = computed(() => { return outputItems.value case 'all': default: - return [...inputItems.value, ...outputItems.value] + return allItems.value } })