From 35d53c2c750a9e5de172993b42a0a53ebb3bda62 Mon Sep 17 00:00:00 2001 From: Arjan Singh <1598641+arjansingh@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:12:59 -0800 Subject: [PATCH] feat(WidgetSelectDropdown): support mapped display names (#6602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add the ability for `WidgetSelectDropdown` to leverage `getOptionLabel` for custom display labels. ## Review Focus Will note inline. ## Screenshots https://github.com/user-attachments/assets/0167cc12-e23d-4b6d-8f7f-74fd97a18397 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6602-feat-WidgetSelectDropdown-support-mapped-display-names-2a26d73d365081709c56c846e3455339) by [Unito](https://www.unito.io) --- src/composables/node/useNodeImage.ts | 1 + .../components/WidgetSelectDropdown.vue | 19 ++ .../form/dropdown/FormDropdownInput.vue | 2 +- .../form/dropdown/FormDropdownMenu.vue | 1 + .../form/dropdown/FormDropdownMenuItem.vue | 3 +- .../widgets/components/form/dropdown/types.ts | 1 + .../components/WidgetSelectDropdown.test.ts | 174 ++++++++++++++++++ 7 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts diff --git a/src/composables/node/useNodeImage.ts b/src/composables/node/useNodeImage.ts index aad473ae7..792720724 100644 --- a/src/composables/node/useNodeImage.ts +++ b/src/composables/node/useNodeImage.ts @@ -168,6 +168,7 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => { const hasWidget = node.widgets?.some((w) => w.name === VIDEO_WIDGET_NAME) if (!hasWidget) { const widget = node.addDOMWidget(VIDEO_WIDGET_NAME, 'video', container, { + canvasOnly: true, hideOnZoom: false }) widget.serialize = false diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index 1d0b139ee..edd5a0257 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -67,6 +67,23 @@ const filterOptions = ref([ ]) 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. + */ +function getDisplayLabel(value: string): string { + const getOptionLabel = props.widget.options?.getOptionLabel + if (!getOptionLabel) return value + + try { + return getOptionLabel(value) + } catch (e) { + console.error('Failed to map value:', e) + return value + } +} + const inputItems = computed(() => { const values = props.widget.options?.values || [] @@ -78,6 +95,7 @@ const inputItems = computed(() => { id: `input-${index}`, mediaSrc: getMediaUrl(value, 'input'), name: value, + label: getDisplayLabel(value), metadata: '' })) }) @@ -108,6 +126,7 @@ const outputItems = computed(() => { id: `output-${index}`, mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'), name: output, + label: getDisplayLabel(output), metadata: '' })) }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue index 37bcea141..c342f8b89 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue @@ -77,7 +77,7 @@ const theButtonStyle = computed(() => {{ props.placeholder }} - {{ selectedItems.map((item) => (item as any)?.name).join(', ') }} + {{ selectedItems.map((item) => item.label ?? item.name).join(', ') }} diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue index 6d7b520eb..776b6a2d3 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue @@ -86,6 +86,7 @@ const searchQuery = defineModel('searchQuery') :selected="isSelected(item, index)" :media-src="item.mediaSrc" :name="item.name" + :label="item.label" :metadata="item.metadata" :layout="layoutMode" @click="emit('item-click', item, index)" diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuItem.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuItem.vue index 22cf1fd0f..793363af7 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuItem.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuItem.vue @@ -12,6 +12,7 @@ interface Props { selected: boolean mediaSrc: string name: string + label?: string metadata?: string layout?: LayoutMode } @@ -139,7 +140,7 @@ function handleVideoLoad(event: Event) { ) " > - {{ name }} + {{ label ?? name }} {{ diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/types.ts b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/types.ts index 2b43fc127..6b493bd30 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/types.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/types.ts @@ -9,6 +9,7 @@ export interface DropdownItem { id: SelectedKey mediaSrc: string // URL for image, video, or other media name: string + label?: string metadata: string } export interface SortOption { diff --git a/tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts new file mode 100644 index 000000000..7c7c2dd73 --- /dev/null +++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts @@ -0,0 +1,174 @@ +import { createTestingPinia } from '@pinia/testing' +import { mount } from '@vue/test-utils' +import type { VueWrapper } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import type { ComponentPublicInstance } from 'vue' +import { describe, expect, it, vi } from 'vitest' + +import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import type { DropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types' +import type { SimplifiedWidget } from '@/types/simplifiedWidget' + +import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue' + +interface WidgetSelectDropdownInstance extends ComponentPublicInstance { + inputItems: DropdownItem[] + outputItems: DropdownItem[] + updateSelectedItems: (selectedSet: Set) => void +} + +describe('WidgetSelectDropdown custom label mapping', () => { + const createMockWidget = ( + value: string = 'img_001.png', + options: { + values?: string[] + getOptionLabel?: (value: string | null) => string + } = {}, + spec?: ComboInputSpec + ): SimplifiedWidget => ({ + name: 'test_image_select', + type: 'combo', + value, + options: { + values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'], + ...options + }, + spec + }) + + const mountComponent = ( + widget: SimplifiedWidget, + modelValue: string | number | undefined, + assetKind: 'image' | 'video' | 'audio' = 'image' + ): VueWrapper => { + return mount(WidgetSelectDropdown, { + props: { + widget, + modelValue, + assetKind, + allowUpload: true, + uploadFolder: 'input' + }, + global: { + plugins: [PrimeVue, createTestingPinia()] + } + }) as unknown as VueWrapper + } + + describe('when custom labels are not provided', () => { + it('uses values as labels when no mapping provided', () => { + const widget = createMockWidget('img_001.png') + const wrapper = mountComponent(widget, 'img_001.png') + + const inputItems = wrapper.vm.inputItems + expect(inputItems).toHaveLength(3) + expect(inputItems[0].name).toBe('img_001.png') + expect(inputItems[0].label).toBe('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('hash789.png') + }) + }) + + describe('when custom labels are provided via getOptionLabel', () => { + it('displays custom labels while preserving original values', () => { + const getOptionLabel = vi.fn((value: string | null) => { + if (!value) return 'No file' + const mapping: Record = { + 'img_001.png': 'Vacation Photo', + 'photo_abc.jpg': 'Family Portrait', + 'hash789.png': 'Sunset Beach' + } + return mapping[value] || value + }) + + const widget = createMockWidget('img_001.png', { + getOptionLabel + }) + const wrapper = mountComponent(widget, 'img_001.png') + + const inputItems = wrapper.vm.inputItems + expect(inputItems).toHaveLength(3) + expect(inputItems[0].name).toBe('img_001.png') + expect(inputItems[0].label).toBe('Vacation Photo') + expect(inputItems[1].name).toBe('photo_abc.jpg') + expect(inputItems[1].label).toBe('Family Portrait') + expect(inputItems[2].name).toBe('hash789.png') + expect(inputItems[2].label).toBe('Sunset Beach') + + expect(getOptionLabel).toHaveBeenCalledWith('img_001.png') + expect(getOptionLabel).toHaveBeenCalledWith('photo_abc.jpg') + expect(getOptionLabel).toHaveBeenCalledWith('hash789.png') + }) + + it('emits original values when items with custom labels are selected', async () => { + const getOptionLabel = vi.fn((value: string | null) => { + if (!value) return 'No file' + return `Custom: ${value}` + }) + + const widget = createMockWidget('img_001.png', { + getOptionLabel + }) + const wrapper = mountComponent(widget, 'img_001.png') + + // Simulate selecting an item + const selectedSet = new Set(['input-1']) // index 1 = photo_abc.jpg + wrapper.vm.updateSelectedItems(selectedSet) + + // Should emit the original value, not the custom label + expect(wrapper.emitted('update:modelValue')).toBeDefined() + expect(wrapper.emitted('update:modelValue')![0]).toEqual([ + 'photo_abc.jpg' + ]) + }) + + it('falls back to original value when label mapping fails', () => { + const getOptionLabel = vi.fn((value: string | null) => { + if (value === 'photo_abc.jpg') { + throw new Error('Mapping failed') + } + return `Labeled: ${value}` + }) + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + 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') + + expect(consoleErrorSpy).toHaveBeenCalled() + consoleErrorSpy.mockRestore() + }) + }) + + describe('output items with custom label mapping', () => { + it('applies custom label mapping to output items from queue history', () => { + const getOptionLabel = vi.fn((value: string | null) => { + if (!value) return 'No file' + return `Output: ${value}` + }) + + const widget = createMockWidget('img_001.png', { + getOptionLabel + }) + const wrapper = mountComponent(widget, 'img_001.png') + + const outputItems = wrapper.vm.outputItems + expect(outputItems).toBeDefined() + expect(Array.isArray(outputItems)).toBe(true) + }) + }) +})