diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/dragged-node1-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/dragged-node1-chromium-linux.png index 9bbb027fbd..fb2a06c6ff 100644 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/dragged-node1-chromium-linux.png and b/browser_tests/tests/interaction.spec.ts-snapshots/dragged-node1-chromium-linux.png differ 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 b4f2e3de58..9be935f05d 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/locales/en/main.json b/src/locales/en/main.json index 4681dd5b68..163563dd1a 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -94,6 +94,8 @@ "openNewIssue": "Open New Issue", "showReport": "Show Report", "imageFailedToLoad": "Image failed to load", + "imageDoesNotExist": "Image does not exist", + "unknownFile": "Unknown file", "reconnecting": "Reconnecting", "reconnected": "Reconnected", "delete": "Delete", diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts b/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts index d6be080ba6..bb0f3fd0c0 100644 --- a/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts +++ b/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts @@ -29,6 +29,8 @@ const i18n = createI18n({ failedToDownloadImage: 'Failed to download image', calculatingDimensions: 'Calculating dimensions', imageFailedToLoad: 'Image failed to load', + imageDoesNotExist: 'Image does not exist', + unknownFile: 'Unknown file', loading: 'Loading' } } diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.vue b/src/renderer/extensions/vueNodes/components/ImagePreview.vue index c0f812a2bc..5d1e5ad8eb 100644 --- a/src/renderer/extensions/vueNodes/components/ImagePreview.vue +++ b/src/renderer/extensions/vueNodes/components/ImagePreview.vue @@ -321,10 +321,11 @@ const handleKeyDown = (event: KeyboardEvent) => { } const getImageFilename = (url: string): string => { + if (!url) return t('g.imageDoesNotExist') try { - return new URL(url).searchParams.get('filename') || 'Unknown file' + return new URL(url).searchParams.get('filename') || t('g.unknownFile') } catch { - return 'Invalid URL' + return t('g.imageDoesNotExist') } } diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts index 3a9e143397..1ea02d6970 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts @@ -2,16 +2,31 @@ import { createTestingPinia } from '@pinia/testing' import { mount } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils' import PrimeVue from 'primevue/config' +import { computed } from 'vue' import type { ComponentPublicInstance } from 'vue' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types' import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { SimplifiedWidget } from '@/types/simplifiedWidget' import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue' +const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] })) +vi.mock( + '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData', + () => ({ + useAssetWidgetData: () => ({ + category: computed(() => 'checkpoints'), + assets: computed(() => mockAssetsData.items), + isLoading: computed(() => false), + error: computed(() => null) + }) + }) +) + const i18n = createI18n({ legacy: false, locale: 'en', @@ -306,3 +321,133 @@ describe('WidgetSelectDropdown custom label mapping', () => { }) }) }) + +describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => { + interface CloudModeInstance extends ComponentPublicInstance { + dropdownItems: FormDropdownItem[] + displayItems: FormDropdownItem[] + selectedSet: Set + } + + const createTestAsset = ( + id: string, + name: string, + preview_url: string + ): AssetItem => ({ + id, + name, + preview_url, + tags: [] + }) + + const createCloudModeWidget = ( + value: string = 'model.safetensors' + ): SimplifiedWidget => ({ + name: 'test_model_select', + type: 'combo', + value, + options: { + values: [], + nodeType: 'CheckpointLoaderSimple' + } + }) + + const mountCloudComponent = ( + widget: SimplifiedWidget, + modelValue: string | undefined + ): VueWrapper => { + return mount(WidgetSelectDropdown, { + props: { + widget, + modelValue, + assetKind: 'model', + isAssetMode: true, + nodeType: 'CheckpointLoaderSimple' + }, + global: { + plugins: [PrimeVue, createTestingPinia(), i18n] + } + }) as unknown as VueWrapper + } + + beforeEach(() => { + mockAssetsData.items = [] + }) + + it('does not include missing items in cloud asset mode dropdown', () => { + mockAssetsData.items = [ + createTestAsset( + 'asset-1', + 'existing_model.safetensors', + 'https://example.com/preview.jpg' + ) + ] + + const widget = createCloudModeWidget('missing_model.safetensors') + const wrapper = mountCloudComponent(widget, 'missing_model.safetensors') + + const dropdownItems = wrapper.vm.dropdownItems + expect(dropdownItems).toHaveLength(1) + expect(dropdownItems[0].name).toBe('existing_model.safetensors') + expect( + dropdownItems.some((item) => item.name === 'missing_model.safetensors') + ).toBe(false) + }) + + it('shows only available cloud assets in dropdown', () => { + mockAssetsData.items = [ + createTestAsset( + 'asset-1', + 'model_a.safetensors', + 'https://example.com/a.jpg' + ), + createTestAsset( + 'asset-2', + 'model_b.safetensors', + 'https://example.com/b.jpg' + ) + ] + + const widget = createCloudModeWidget('model_a.safetensors') + const wrapper = mountCloudComponent(widget, 'model_a.safetensors') + + const dropdownItems = wrapper.vm.dropdownItems + expect(dropdownItems).toHaveLength(2) + expect(dropdownItems.map((item) => item.name)).toEqual([ + 'model_a.safetensors', + 'model_b.safetensors' + ]) + }) + + it('returns empty dropdown when no cloud assets available', () => { + mockAssetsData.items = [] + + const widget = createCloudModeWidget('missing_model.safetensors') + const wrapper = mountCloudComponent(widget, 'missing_model.safetensors') + + const dropdownItems = wrapper.vm.dropdownItems + expect(dropdownItems).toHaveLength(0) + }) + + it('includes missing cloud asset in displayItems for input field visibility', () => { + mockAssetsData.items = [ + createTestAsset( + 'asset-1', + 'existing_model.safetensors', + 'https://example.com/preview.jpg' + ) + ] + + const widget = createCloudModeWidget('missing_model.safetensors') + const wrapper = mountCloudComponent(widget, 'missing_model.safetensors') + + const displayItems = wrapper.vm.displayItems + expect(displayItems).toHaveLength(2) + expect(displayItems[0].name).toBe('missing_model.safetensors') + expect(displayItems[0].id).toBe('missing-missing_model.safetensors') + expect(displayItems[1].name).toBe('existing_model.safetensors') + + const selectedSet = wrapper.vm.selectedSet + expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index 3c6ca71cc7..1680c9690c 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -254,9 +254,8 @@ const baseModelFilteredAssetItems = computed(() => const allItems = computed(() => { if (props.isAssetMode && assetData) { - if (missingValueItem.value) { - return [missingValueItem.value, ...baseModelFilteredAssetItems.value] - } + // Cloud assets not in user's library shouldn't appear as search results (COM-14333). + // Unlike local mode, cloud users can't access files they don't own. return baseModelFilteredAssetItems.value } return [ @@ -282,6 +281,17 @@ const dropdownItems = computed(() => { } }) +/** + * Items used for display in the input field. In cloud mode, includes + * missing items so users can see their selected value even if not in library. + */ +const displayItems = computed(() => { + if (props.isAssetMode && assetData && missingValueItem.value) { + return [missingValueItem.value, ...baseModelFilteredAssetItems.value] + } + return dropdownItems.value +}) + const mediaPlaceholder = computed(() => { const options = props.widget.options @@ -332,18 +342,20 @@ const acceptTypes = computed(() => { const layoutMode = ref(props.defaultLayoutMode ?? 'grid') watch( - [modelValue, dropdownItems], - ([currentValue, _dropdownItems]) => { + [modelValue, displayItems], + ([currentValue]) => { if (currentValue === undefined) { selectedSet.value.clear() return } - const item = dropdownItems.value.find((item) => item.name === currentValue) - if (item) { + const item = displayItems.value.find((item) => item.name === currentValue) + if (!item) { selectedSet.value.clear() - selectedSet.value.add(item.id) + return } + selectedSet.value.clear() + selectedSet.value.add(item.id) }, { immediate: true } ) @@ -461,6 +473,7 @@ function getMediaUrl( v-model:ownership-selected="ownershipSelected" v-model:base-model-selected="baseModelSelected" :items="dropdownItems" + :display-items="displayItems" :placeholder="mediaPlaceholder" :multiple="false" :uploadable diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue index 08e35c6483..0acfe80447 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue @@ -18,6 +18,8 @@ import type { FormDropdownItem, LayoutMode, SortOption } from './types' interface Props { items: FormDropdownItem[] + /** Items used for display in the input field. Falls back to items if not provided. */ + displayItems?: FormDropdownItem[] placeholder?: string /** * If true, allows multiple selections. If a number is provided, @@ -193,6 +195,7 @@ async function customSearcher( :is-open :placeholder="placeholderText" :items + :display-items :max-selectable :selected :uploadable 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 e8e5b21bfa..7470ebac92 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue @@ -10,6 +10,8 @@ interface Props { isOpen?: boolean placeholder?: string items: FormDropdownItem[] + /** Items used for display in the input field. Falls back to items if not provided. */ + displayItems?: FormDropdownItem[] selected: Set maxSelectable: number uploadable: boolean @@ -28,7 +30,8 @@ const emit = defineEmits<{ }>() const selectedItems = computed(() => { - return props.items.filter((item) => props.selected.has(item.id)) + const itemsToSearch = props.displayItems ?? props.items + return itemsToSearch.filter((item) => props.selected.has(item.id)) }) const theButtonStyle = computed(() =>