Add node pack version selector dropdown (#2973)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-03-11 06:48:25 -07:00
committed by GitHub
parent b347693f4d
commit a046e00bc3
12 changed files with 622 additions and 23 deletions

View File

@@ -1,23 +1,85 @@
<template>
<Button
v-if="version"
:label="version"
severity="secondary"
icon="pi pi-chevron-right"
icon-pos="right"
class="rounded-xl text-xs tracking-tighter"
:pt="{
root: { class: 'p-0' },
label: { class: 'pl-2 pr-0 py-0.5' },
icon: { class: 'text-xs pl-0 pr-2 py-0.5' }
}"
/>
<div class="relative">
<Button
v-if="displayVersion"
:label="displayVersion"
severity="secondary"
icon="pi pi-chevron-right"
icon-pos="right"
class="rounded-xl text-xs tracking-tighter p-0"
:pt="{
label: { class: 'pl-2 pr-0 py-0.5' },
icon: { class: 'text-xs pl-0 pr-2 py-0.5' }
}"
aria-haspopup="true"
@click="toggleVersionSelector"
/>
<Popover
ref="popoverRef"
:pt="{
content: { class: 'px-0' }
}"
>
<PackVersionSelectorPopover
:selected-version="selectedVersion"
:node-pack="nodePack"
@select="onSelect"
@cancel="closeVersionSelector"
@apply="applyVersionSelection"
/>
</Popover>
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
defineProps<{
version: string | undefined
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { nodePack, version = SelectedVersion.NIGHTLY } = defineProps<{
nodePack: components['schemas']['Node']
version?: string
}>()
const emit = defineEmits<{
'update:version': [version: string]
}>()
const popoverRef = ref()
const selectedVersion = ref<string>(version)
const displayVersion = computed(() => {
if (selectedVersion.value === SelectedVersion.LATEST) {
// If there is no version, treat as unclaimed GitHub pack and use nightly
return nodePack?.latest_version?.version || SelectedVersion.NIGHTLY
}
return selectedVersion.value
})
const toggleVersionSelector = (event: Event) => {
popoverRef.value.toggle(event)
}
const closeVersionSelector = () => {
popoverRef.value.hide()
}
const onSelect = (newVersion: string) => {
selectedVersion.value = newVersion
}
const applyVersionSelection = (newVersion: string) => {
selectedVersion.value = newVersion
emit('update:version', newVersion)
// TODO: after manager store added, install the pack here
closeVersionSelector()
}
whenever(() => version, onSelect)
</script>

View File

@@ -0,0 +1,126 @@
<template>
<div class="w-64 mt-2">
<span class="pl-3 text-muted text-md font-semibold opacity-70">
{{ $t('manager.selectVersion') }}
</span>
<div
v-if="isLoading"
class="text-center text-muted py-4 flex flex-col items-center"
>
<ProgressSpinner class="w-8 h-8 mb-2" />
{{ $t('manager.loadingVersions') }}
</div>
<div v-else-if="allVersionOptions.length === 0" class="py-2">
<NoResultsPlaceholder
:title="$t('g.noResultsFound')"
:message="$t('manager.tryAgainLater')"
icon="pi pi-exclamation-circle"
class="p-0"
/>
</div>
<Listbox
v-else
v-model="currentSelection"
option-label="label"
option-value="value"
:options="allVersionOptions"
:highlight-on-select="false"
class="my-3 w-full max-h-[50vh] border-none"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">
<span>{{ slotProps.option.label }}</span>
<i
v-if="currentSelection === slotProps.option.value"
class="pi pi-check text-highlight"
></i>
</div>
</template>
</Listbox>
<ContentDivider class="my-2" />
<div class="flex justify-end gap-2 p-1 px-3">
<Button
text
severity="secondary"
:label="$t('g.cancel')"
@click="emit('cancel')"
/>
<Button
severity="secondary"
:label="$t('g.install')"
@click="emit('apply', currentSelection ?? SelectedVersion.LATEST)"
class="py-3 px-4 dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { nodePack, selectedVersion = SelectedVersion.NIGHTLY } = defineProps<{
nodePack: components['schemas']['Node']
selectedVersion?: string
}>()
const emit = defineEmits<{
cancel: []
apply: [version: string]
}>()
const { t } = useI18n()
const registryService = useComfyRegistryService()
const currentSelection = ref<string>(selectedVersion)
const fetchVersions = async () => {
if (!nodePack?.id) return []
return (await registryService.getPackVersions(nodePack.id)) || []
}
const {
isLoading,
state: versions,
execute: startFetchVersions
} = useAsyncState(fetchVersions, [])
const specialOptions = computed(() => [
{
value: SelectedVersion.NIGHTLY,
label: t('manager.nightlyVersion')
},
{
value: SelectedVersion.LATEST,
label: t('manager.latestVersion')
}
])
const versionOptions = computed(() =>
versions.value.map((version) => ({
value: version.version,
label: version.version
}))
)
const allVersionOptions = computed(() => [
...specialOptions.value,
...versionOptions.value
])
watch(
() => nodePack,
() => startFetchVersions(),
{ deep: true }
)
</script>

View File

@@ -0,0 +1,159 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Popover from 'primevue/popover'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionBadge from '../PackVersionBadge.vue'
import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue'
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: {
version: '1.0.0'
}
}
describe('PackVersionBadge', () => {
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackVersionBadge, {
props: {
nodePack: mockNodePack,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Popover,
PackVersionSelectorPopover
}
}
})
}
it('renders with default version (NIGHTLY)', () => {
const wrapper = mountComponent()
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
})
it('renders with provided version', () => {
const version = '2.0.0'
const wrapper = mountComponent({ props: { version } })
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(version)
})
it('shows actual latest (semantic) version prop when version is set to latest', () => {
const wrapper = mountComponent({
props: { version: SelectedVersion.LATEST }
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(mockNodePack.latest_version.version)
})
it('toggles the popover when button is clicked', async () => {
const wrapper = mountComponent()
// Spy on the toggle method
const popoverToggleSpy = vi.fn()
const popover = wrapper.findComponent(Popover)
popover.vm.toggle = popoverToggleSpy
// Open the popover
await wrapper.findComponent(Button).trigger('click')
// Verify that the toggle method was called
expect(popoverToggleSpy).toHaveBeenCalled()
})
it('emits update:version event when version is selected', async () => {
const wrapper = mountComponent()
// Open the popover
await wrapper.findComponent(Button).trigger('click')
// Simulate the popover emitting an apply event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('apply', '3.0.0')
await nextTick()
// Check if the update:version event was emitted with the correct value
expect(wrapper.emitted('update:version')).toBeTruthy()
expect(wrapper.emitted('update:version')![0]).toEqual(['3.0.0'])
})
it('closes the popover when cancel is clicked', async () => {
const wrapper = mountComponent()
// Open the popover
await wrapper.findComponent(Button).trigger('click')
// Simulate the popover emitting a cancel event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('cancel')
await nextTick()
// Check if the popover is hidden
expect(wrapper.findComponent(Popover).isVisible()).toBe(false)
})
it('updates displayed version when version prop changes', async () => {
const wrapper = mountComponent({ props: { version: '1.0.0' } })
expect(wrapper.findComponent(Button).props('label')).toBe('1.0.0')
// Update the version prop
await wrapper.setProps({ version: '2.0.0' })
// Check if the displayed version was updated
expect(wrapper.findComponent(Button).props('label')).toBe('2.0.0')
})
it('handles null or undefined nodePack', async () => {
const wrapper = mountComponent({ props: { nodePack: null } })
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
// Should not crash when clicking the button
await button.trigger('click')
expect(wrapper.findComponent(Popover).isVisible()).toBe(false)
})
it('handles missing latest_version (unclaimed pack) by falling back to NIGHTLY', async () => {
const incompleteNodePack = { id: 'test-pack', name: 'Test Pack' }
const wrapper = mountComponent({
props: {
nodePack: incompleteNodePack,
version: SelectedVersion.LATEST
}
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
// Should fallback to nightly string when latest_version is missing
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
})
})

View File

@@ -0,0 +1,190 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Listbox from 'primevue/listbox'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue'
const mockVersions = [
{ version: '1.0.0', createdAt: '2023-01-01' },
{ version: '0.9.0', createdAt: '2022-12-01' },
{ version: '0.8.0', createdAt: '2022-11-01' }
]
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: { version: '1.0.0' }
}
const mockGetPackVersions = vi.fn().mockResolvedValue(mockVersions)
vi.mock('@/services/comfyRegistryService', () => ({
useComfyRegistryService: vi.fn(() => ({
getPackVersions: mockGetPackVersions
}))
}))
const waitForPromises = async () => {
await new Promise((resolve) => setTimeout(resolve, 16))
await nextTick()
}
describe('PackVersionSelectorPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetPackVersions.mockClear()
mockGetPackVersions.mockResolvedValue(mockVersions)
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackVersionSelectorPopover, {
props: {
nodePack: mockNodePack,
selectedVersion: SelectedVersion.NIGHTLY,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Listbox
}
}
})
}
it('fetches versions on mount', async () => {
mountComponent()
await waitForPromises()
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
})
it('shows loading state while fetching versions', async () => {
// Delay the promise resolution
mockGetPackVersions.mockImplementationOnce(
() =>
new Promise((resolve) => setTimeout(() => resolve(mockVersions), 1000))
)
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Loading versions...')
})
it('displays special options and version options in the listbox', async () => {
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
const options = listbox.props('options')!
expect(options.length).toBe(5) // 2 special options + 3 version options
// Check special options
expect(options[0].value).toBe(SelectedVersion.NIGHTLY)
expect(options[1].value).toBe(SelectedVersion.LATEST)
// Check version options
expect(options[2].value).toBe('1.0.0')
expect(options[3].value).toBe('0.9.0')
expect(options[4].value).toBe('0.8.0')
})
it('initializes with the provided selectedVersion prop', async () => {
const selectedVersion = '0.9.0'
const wrapper = mountComponent({ props: { selectedVersion } })
await waitForPromises()
// Check that the listbox has the correct model value
const listbox = wrapper.findComponent(Listbox)
expect(listbox.props('modelValue')).toBe(selectedVersion)
})
it('emits cancel event when cancel button is clicked', async () => {
const wrapper = mountComponent()
await waitForPromises()
const cancelButton = wrapper.findAllComponents(Button)[0]
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('emits apply event with current selection when apply button is clicked', async () => {
const selectedVersion = '0.9.0'
const wrapper = mountComponent({ props: { selectedVersion } })
await waitForPromises()
const applyButton = wrapper.findAllComponents(Button)[1]
await applyButton.trigger('click')
expect(wrapper.emitted('apply')).toBeTruthy()
expect(wrapper.emitted('apply')![0]).toEqual([selectedVersion])
})
it('emits apply event with LATEST when no selection and apply button is clicked', async () => {
const wrapper = mountComponent({ props: { selectedVersion: null } })
await waitForPromises()
const applyButton = wrapper.findAllComponents(Button)[1]
await applyButton.trigger('click')
expect(wrapper.emitted('apply')).toBeTruthy()
expect(wrapper.emitted('apply')![0]).toEqual([SelectedVersion.LATEST])
})
it('is reactive to nodePack prop changes', async () => {
const wrapper = mountComponent()
await waitForPromises()
// Clear mock calls to check if getPackVersions is called again
mockGetPackVersions.mockClear()
// Update the nodePack prop
const newNodePack = { ...mockNodePack, id: 'new-test-pack' }
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
})
describe('Unclaimed GitHub packs handling', () => {
it('falls back to nightly when comfy-api returns null when fetching versions', async () => {
mockGetPackVersions.mockResolvedValueOnce(null)
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
})
it('falls back to nightly when component mounts with no versions (unclaimed pack)', async () => {
mockGetPackVersions.mockResolvedValueOnce([])
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
})
})
})

View File

@@ -21,7 +21,11 @@
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
<PackVersionBadge :version="nodePack.latest_version?.version" />
<PackVersionBadge
:node-pack="nodePack"
:version="selectedVersion"
@update:version="updateSelectedVersion"
/>
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
@@ -32,7 +36,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
@@ -40,6 +44,7 @@ import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBad
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackCardHeader from '@/components/dialog/content/manager/packCard/PackCardHeader.vue'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
@@ -49,16 +54,29 @@ interface InfoItem {
value: string | number | undefined
}
const { t, d } = useI18n()
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { t, d } = useI18n()
const packCardHeaderRef = ref(null)
const selectedVersion = ref<string>(
nodePack.latest_version?.version || SelectedVersion.NIGHTLY
)
const updateSelectedVersion = (version: string) => {
selectedVersion.value = version
if (packCardHeaderRef.value) {
packCardHeaderRef.value.updateVersion?.(version)
}
}
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
label: `${t('manager.createdBy')}`,
label: t('manager.createdBy'),
// TODO: handle all Comfy Registry publisher types dynamically (e.g., organizations, multiple authors)
value: nodePack.publisher?.name
},
{

View File

@@ -66,7 +66,12 @@
<span v-if="nodePack.publisher?.name">
{{ nodePack.publisher.name }}
</span>
<span v-if="nodePack.latest_version">
<PackVersionBadge
v-if="isPackInstalled"
:node-pack="nodePack"
v-model:version="selectedVersion"
/>
<span v-else-if="nodePack.latest_version">
{{ nodePack.latest_version.version }}
</span>
</div>
@@ -88,15 +93,24 @@
<script setup lang="ts">
import Card from 'primevue/card'
import { computed, ref } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import type { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
defineProps<{
const { nodePack, isSelected = false } = defineProps<{
nodePack: components['schemas']['Node']
isSelected?: boolean
}>()
const selectedVersion = ref<string | undefined>(undefined)
const isPackInstalled = computed(
() =>
// TODO: after manager store added
// managerStore.isPackInstalled(nodePack?.id)
true
)
</script>

View File

@@ -89,13 +89,18 @@
"name": "Name",
"category": "Category",
"sort": "Sort",
"filter": "Filter"
"filter": "Filter",
"apply": "Apply"
},
"manager": {
"title": "Custom Nodes Manager",
"loadingVersions": "Loading versions...",
"selectVersion": "Select Version",
"downloads": "Downloads",
"repository": "Repository",
"license": "License",
"nightlyVersion": "Nightly",
"latestVersion": "Latest",
"createdBy": "Created By",
"totalNodes": "Total Nodes",
"discoverCommunityContent": "Discover community-made Node Packs, Extensions, and more...",

View File

@@ -117,6 +117,7 @@
"about": "À propos",
"add": "Ajouter",
"all": "Tout",
"apply": "Appliquer",
"back": "Retour",
"cancel": "Annuler",
"capture": "capture",
@@ -383,13 +384,17 @@
},
"installSelected": "Installer sélectionné",
"lastUpdated": "Dernière mise à jour",
"latestVersion": "Dernière",
"license": "Licence",
"loadingVersions": "Chargement des versions...",
"nightlyVersion": "Nocturne",
"noDescription": "Aucune description disponible",
"noResultsFound": "Aucun résultat trouvé correspondant à votre recherche.",
"nodePack": "Pack de Nœuds",
"packsSelected": "Packs sélectionnés",
"repository": "Référentiel",
"searchPlaceholder": "Recherche",
"selectVersion": "Sélectionner la version",
"sort": {
"downloads": "Le plus populaire",
"rating": "Évaluation"

View File

@@ -117,6 +117,7 @@
"about": "情報",
"add": "追加",
"all": "すべて",
"apply": "適用する",
"back": "戻る",
"cancel": "キャンセル",
"capture": "キャプチャ",
@@ -383,13 +384,17 @@
},
"installSelected": "選択したものをインストール",
"lastUpdated": "最終更新日",
"latestVersion": "最新",
"license": "ライセンス",
"loadingVersions": "バージョンを読み込んでいます...",
"nightlyVersion": "ナイトリー",
"noDescription": "説明はありません",
"noResultsFound": "検索に一致する結果が見つかりませんでした。",
"nodePack": "ノードパック",
"packsSelected": "選択したパック",
"repository": "リポジトリ",
"searchPlaceholder": "検索",
"selectVersion": "バージョンを選択",
"sort": {
"downloads": "最も人気",
"rating": "評価"

View File

@@ -117,6 +117,7 @@
"about": "정보",
"add": "추가",
"all": "모두",
"apply": "적용",
"back": "뒤로",
"cancel": "취소",
"capture": "캡처",
@@ -383,13 +384,17 @@
},
"installSelected": "선택한 항목 설치",
"lastUpdated": "마지막 업데이트",
"latestVersion": "최신",
"license": "라이선스",
"loadingVersions": "버전 로딩 중...",
"nightlyVersion": "야간",
"noDescription": "설명이 없습니다",
"noResultsFound": "검색과 일치하는 결과가 없습니다.",
"nodePack": "노드 팩",
"packsSelected": "선택한 팩",
"repository": "저장소",
"searchPlaceholder": "검색",
"selectVersion": "버전 선택",
"sort": {
"downloads": "가장 인기 있는",
"rating": "평점"

View File

@@ -117,6 +117,7 @@
"about": "О программе",
"add": "Добавить",
"all": "Все",
"apply": "Применить",
"back": "Назад",
"cancel": "Отмена",
"capture": "захват",
@@ -383,13 +384,17 @@
},
"installSelected": "Установить выбранное",
"lastUpdated": "Последнее обновление",
"latestVersion": "Последняя",
"license": "Лицензия",
"loadingVersions": "Загрузка версий...",
"nightlyVersion": "Ночная",
"noDescription": "Описание отсутствует",
"noResultsFound": "По вашему запросу ничего не найдено.",
"nodePack": "Пакет Узлов",
"packsSelected": "Выбрано пакетов",
"repository": "Репозиторий",
"searchPlaceholder": "Поиск",
"selectVersion": "Выберите версию",
"sort": {
"downloads": "Самые популярные",
"rating": "Рейтинг"

View File

@@ -117,6 +117,7 @@
"about": "关于",
"add": "添加",
"all": "全部",
"apply": "应用",
"back": "返回",
"cancel": "取消",
"capture": "捕获",
@@ -383,13 +384,17 @@
},
"installSelected": "安装选定",
"lastUpdated": "最后更新",
"latestVersion": "最新",
"license": "许可证",
"loadingVersions": "正在加载版本...",
"nightlyVersion": "每夜",
"noDescription": "无可用描述",
"noResultsFound": "未找到符合您搜索的结果。",
"nodePack": "节点包",
"packsSelected": "选定的包",
"repository": "仓库",
"searchPlaceholder": "搜索",
"selectVersion": "选择版本",
"sort": {
"downloads": "最受欢迎",
"rating": "评级"